From 665a3a3bfac77518d213f480e6e27802e49b473a Mon Sep 17 00:00:00 2001 From: grimsace Date: Mon, 18 May 2026 08:35:44 -0500 Subject: [PATCH] major refactor/rework for better code organization --- src/app.rs | 591 ++++ src/interact.rs | 2378 ++++++++++++++ src/{layout.rs => layout/generation.rs} | 627 +--- src/layout/mod.rs | 7 + src/layout/types.rs | 150 + src/layout/utils.rs | 467 +++ src/main.rs | 3825 +---------------------- src/rendering.rs | 770 +++++ src/ui/mod.rs | 436 +++ src/{ui.rs => ui/tabs.rs} | 714 +---- src/ui/widgets.rs | 282 ++ 11 files changed, 5105 insertions(+), 5142 deletions(-) create mode 100644 src/app.rs create mode 100644 src/interact.rs rename src/{layout.rs => layout/generation.rs} (75%) create mode 100644 src/layout/mod.rs create mode 100644 src/layout/types.rs create mode 100644 src/layout/utils.rs create mode 100644 src/rendering.rs create mode 100644 src/ui/mod.rs rename src/{ui.rs => ui/tabs.rs} (54%) create mode 100644 src/ui/widgets.rs diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..71194a8 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,591 @@ +use std::collections::HashSet; +use std::sync::mpsc; +use std::thread; + +use crate::exporter; +use crate::interact::{AddCorridorDrag, DragState, HoverMarker, ResizeState, draw_grid}; +use crate::layout::{ + self, DoorSettings, DungeonLayout, WindowSettings, populate_random_markers, populate_stairs, +}; +use crate::rendering::draw_layout; +use crate::saveandload; +use crate::settings; +use crate::ui::{AddTool, UiSettings, draw_legend_panel, draw_level_tabs, draw_side_panel}; +use eframe::egui; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ManualDoorKind { + Archway, + Regular, + Locked, + Secret, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppSnapshot { + pub settings: UiSettings, + pub levels: Vec, + pub suppressed_auto_door_edges: HashSet<((usize, usize), (usize, usize))>, +} + +pub struct DungeonApp { + pub settings: UiSettings, + pub levels: Vec, + pub suppressed_auto_door_edges: HashSet<((usize, usize), (usize, usize))>, + pub undo_stack: Vec, + pub redo_stack: Vec, + pub drag_state: Option, + pub export_rx: Option>, + pub export_progress: Option, + pub pending_delete: bool, + pub add_corridor_drag: Option, + pub resize_state: Option, + pub hover_room_idx: Option, + pub hover_corridor_idx: Option, + pub hover_door_idx: Option, + pub hover_text_idx: Option, + pub hover_marker: Option, + pub hover_level_idx: Option, + pub hover_stair_idx: Option, +} + +impl Default for DungeonApp { + fn default() -> Self { + let settings = settings::load_settings().unwrap_or_default(); + let mut app = Self { + settings, + levels: Vec::new(), + suppressed_auto_door_edges: HashSet::new(), + undo_stack: Vec::new(), + redo_stack: Vec::new(), + drag_state: None, + export_rx: None, + export_progress: None, + pending_delete: false, + add_corridor_drag: None, + resize_state: None, + hover_room_idx: None, + hover_corridor_idx: None, + hover_door_idx: None, + hover_text_idx: None, + hover_marker: None, + hover_level_idx: None, + hover_stair_idx: None, + }; + if app.settings.composition_mode { + app.enter_composition_layout(); + } else { + app.levels = generate_all_levels(&app.settings); + app.refresh_stairs(); + } + app + } +} + +impl Drop for DungeonApp { + fn drop(&mut self) { + if let Err(err) = settings::save_settings(&self.settings) { + eprintln!("Failed to save settings: {err}"); + } + } +} + +impl eframe::App for DungeonApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + let panel_result = draw_side_panel( + ctx, + &mut self.settings, + self.export_progress + .map(|progress| (progress.completed, progress.total)), + self.levels.len(), + ); + let undo_requested = ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Z)); + let redo_requested = ctx.input(|i| { + (i.modifiers.command && i.key_pressed(egui::Key::Y)) + || (i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::Z)) + }); + let escape_pressed = ctx.input(|i| i.key_pressed(egui::Key::Escape)); + + if escape_pressed || panel_result.settings_changed { + self.settings.add_tool = AddTool::None; + } + + clamp_dependent_settings(&mut self.settings); + + if undo_requested { + self.undo(); + } else if redo_requested { + self.redo(); + } + + if panel_result.clear_clicked { + self.push_undo_snapshot(); + self.clear_layout(); + } else if panel_result.reset_clicked { + self.push_undo_snapshot(); + if self.settings.composition_mode { + self.enter_composition_layout(); + } else { + self.regenerate_layout(); + } + } else if panel_result.settings_changed && !self.settings.composition_mode { + self.push_undo_snapshot(); + self.regenerate_layout(); + } else if panel_result.save_clicked { + let mut layouts = Vec::new(); + let mut svgs = Vec::new(); + for layout in &self.levels { + layouts.push(layout.clone()); + svgs.push(exporter::build_svg(layout, &self.settings)); + } + if let Err(err) = saveandload::save_dungeon(&self.settings, layouts, svgs) { + eprintln!("{err}"); + } + } else if panel_result.load_clicked { + match saveandload::load_dungeon() { + Ok(data) => { + self.push_undo_snapshot(); + self.settings = data.settings; + self.levels = data.layouts; + self.settings.active_level_index = 0; + } + Err(err) => { + eprintln!("{err}"); + } + } + } else if panel_result.new_level_clicked { + self.push_undo_snapshot(); + if self.settings.composition_mode { + self.levels.push(DungeonLayout::empty( + self.settings.pack_rooms_without_corridors, + )); + } else { + self.levels + .push(generate_level(&self.settings, self.levels.len())); + self.refresh_stairs(); + } + self.settings.active_level_index = self.levels.len() - 1; + } + if ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { + self.pending_delete = true; + } + if panel_result.export_clicked && self.export_rx.is_none() { + let target = match exporter::select_export_target(&self.settings) { + Ok(target) => target, + Err(err) => { + eprintln!("{err}"); + return; + } + }; + + match &target { + exporter::ExportTarget::File(path) => { + if let Some(parent) = path.parent() { + self.settings.last_export_path = Some(parent.to_string_lossy().to_string()); + } + } + exporter::ExportTarget::Folder(path) => { + self.settings.last_export_path = Some(path.to_string_lossy().to_string()); + } + } + + let layouts = self.levels.clone(); + let settings = self.settings.clone(); + let (tx, rx) = mpsc::channel(); + thread::spawn(move || { + let result = exporter::export_to_target(&layouts, &settings, target, &tx); + let _ = tx.send(exporter::ExportEvent::Finished(result)); + }); + self.export_progress = Some(exporter::ExportProgress { + completed: 0, + total: 1, + }); + self.export_rx = Some(rx); + } + + let mut export_finished = false; + if let Some(rx) = &self.export_rx { + loop { + match rx.try_recv() { + Ok(exporter::ExportEvent::Progress(progress)) => { + self.export_progress = Some(progress); + ctx.request_repaint(); + } + Ok(exporter::ExportEvent::Finished(Ok(_))) => { + export_finished = true; + break; + } + Ok(exporter::ExportEvent::Finished(Err(err))) => { + eprintln!("{err}"); + export_finished = true; + break; + } + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + export_finished = true; + break; + } + } + } + } + if export_finished { + self.export_rx = None; + self.export_progress = None; + } + + if draw_legend_panel(ctx, &mut self.settings) { + self.push_undo_snapshot(); + if self.settings.composition_mode { + self.enter_composition_layout(); + } else { + self.regenerate_layout(); + } + } + + egui::CentralPanel::default().show(ctx, |ui| { + self.hover_level_idx = + draw_level_tabs(ui, self.levels.len(), &mut self.settings.active_level_index); + ui.separator(); + + let available = ui.available_size_before_wrap(); + let (response, painter) = ui.allocate_painter(available, egui::Sense::click_and_drag()); + let canvas = response.rect.shrink(12.0); + let geometry = draw_grid(&painter, canvas, self.settings.cols, self.settings.rows); + crate::interact::update_hover_targets(self, ctx, &geometry); + + let level_delete_requested = ctx.input(|i| { + (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) + && self.hover_level_idx.is_some() + }); + if level_delete_requested { + if let Some(level_idx) = self.hover_level_idx { + self.delete_level(level_idx); + } + } + + let resizing = crate::interact::handle_resize(self, ctx, &response, &geometry); + if !resizing { + crate::interact::handle_drag(self, ctx, &response, &geometry); + } + crate::interact::handle_add_tool(self, &response, &geometry); + if self.pending_delete { + if let Some(pointer_pos) = ctx.pointer_interact_pos() { + crate::interact::delete_at_pointer(self, pointer_pos, &geometry); + } + self.pending_delete = false; + } + + if self.settings.active_level_index < self.levels.len() { + draw_layout( + &painter, + &geometry, + &self.levels[self.settings.active_level_index], + self.settings.colorblind_mode, + self.hover_text_idx, + self.hover_marker, + self.hover_stair_idx, + ); + } + crate::interact::draw_add_overlay(self, &painter, &geometry); + crate::interact::draw_add_tool_ghost(self, &painter, &geometry); + crate::interact::draw_resize_overlay(self, &painter, &geometry); + crate::interact::draw_corridor_hover_overlay(self, &painter, &geometry); + crate::interact::draw_door_hover_overlay(self, &painter, &geometry); + }); + } +} + +pub fn generate_all_levels(settings: &UiSettings) -> Vec { + let level_count = if settings.min_levels == settings.max_levels { + settings.min_levels + } else { + let range_seed = crate::seed::derive_seed(settings.seed, 0x1E_7E_1_u64); + (range_seed as usize % (settings.max_levels - settings.min_levels + 1)) + + settings.min_levels + }; + + let mut levels = Vec::with_capacity(level_count); + for i in 0..level_count { + levels.push(generate_level(settings, i)); + } + levels +} + +pub fn generate_level(settings: &UiSettings, level_index: usize) -> DungeonLayout { + let level_seed = crate::seed::derive_seed(settings.seed, level_index as u64); + let layout = layout::generate_layout( + settings.cols, + settings.rows, + settings.room_count, + level_seed, + settings.min_room_size, + settings.max_room_size, + settings.square_rooms_only, + settings.min_corridor_width, + settings.max_corridor_width, + settings.corridor_randomness, + settings.dead_end_rooms_percent, + settings.pack_rooms_without_corridors, + door_settings_from_ui(settings), + window_settings_from_ui(settings), + ); + + populate_random_markers(layout, settings) +} + +pub fn clamp_dependent_settings(settings: &mut UiSettings) { + if settings.min_room_size > settings.max_room_size { + settings.max_room_size = settings.min_room_size; + } + if settings.min_corridor_width == 0 { + settings.min_corridor_width = 1; + } + if settings.min_corridor_width > settings.max_corridor_width { + settings.max_corridor_width = settings.min_corridor_width; + } + if settings.min_window_width > settings.max_window_width { + settings.max_window_width = settings.min_window_width; + } + if settings.min_start_marker_size > settings.max_start_marker_size { + settings.max_start_marker_size = settings.min_start_marker_size; + } + if settings.min_end_marker_size > settings.max_end_marker_size { + settings.max_end_marker_size = settings.min_end_marker_size; + } + if settings.min_start_marker_count > settings.max_start_marker_count { + settings.max_start_marker_count = settings.min_start_marker_count; + } + if settings.min_end_marker_count > settings.max_end_marker_count { + settings.max_end_marker_count = settings.min_end_marker_count; + } + if settings.min_traps_per_area > settings.max_traps_per_area { + settings.max_traps_per_area = settings.min_traps_per_area; + } + if settings.min_monsters_per_area > settings.max_monsters_per_area { + settings.max_monsters_per_area = settings.min_monsters_per_area; + } + if settings.min_levels == 0 { + settings.min_levels = 1; + } + if settings.min_levels > settings.max_levels { + settings.max_levels = settings.min_levels; + } +} + +impl DungeonApp { + pub fn clear_hover_targets(&mut self) { + self.hover_room_idx = None; + self.hover_corridor_idx = None; + self.hover_door_idx = None; + self.hover_text_idx = None; + self.hover_marker = None; + self.hover_level_idx = None; + self.hover_stair_idx = None; + } + + pub fn reset_transient_state(&mut self) { + self.drag_state = None; + self.add_corridor_drag = None; + self.resize_state = None; + self.clear_hover_targets(); + self.pending_delete = false; + } + + pub fn snapshot(&self) -> AppSnapshot { + AppSnapshot { + settings: self.settings.clone(), + levels: self.levels.clone(), + suppressed_auto_door_edges: self.suppressed_auto_door_edges.clone(), + } + } + + pub fn push_undo_snapshot(&mut self) { + let snapshot = self.snapshot(); + if self.undo_stack.last() == Some(&snapshot) { + return; + } + self.undo_stack.push(snapshot); + if self.undo_stack.len() > 100 { + self.undo_stack.remove(0); + } + self.redo_stack.clear(); + } + + pub fn restore_snapshot(&mut self, snapshot: AppSnapshot) { + self.settings = snapshot.settings; + self.levels = snapshot.levels; + self.suppressed_auto_door_edges = snapshot.suppressed_auto_door_edges; + self.reset_transient_state(); + } + + pub fn undo(&mut self) { + let Some(snapshot) = self.undo_stack.pop() else { + return; + }; + self.redo_stack.push(self.snapshot()); + self.restore_snapshot(snapshot); + } + + pub fn redo(&mut self) { + let Some(snapshot) = self.redo_stack.pop() else { + return; + }; + self.undo_stack.push(self.snapshot()); + self.restore_snapshot(snapshot); + } + + pub fn clear_layout(&mut self) { + if self.settings.active_level_index >= self.levels.len() { + return; + } + self.levels[self.settings.active_level_index] = + DungeonLayout::empty(self.settings.pack_rooms_without_corridors); + self.suppressed_auto_door_edges.clear(); + self.reset_transient_state(); + } + + pub fn enter_composition_layout(&mut self) { + self.levels = vec![DungeonLayout::empty( + self.settings.pack_rooms_without_corridors, + )]; + self.settings.active_level_index = 0; + self.settings.export_level_index = 0; + self.suppressed_auto_door_edges.clear(); + self.reset_transient_state(); + } + + pub fn regenerate_layout(&mut self) { + self.drag_state = None; + self.suppressed_auto_door_edges.clear(); + self.levels = generate_all_levels(&self.settings); + self.refresh_stairs(); + if self.settings.active_level_index >= self.levels.len() { + self.settings.active_level_index = 0; + } + } + + pub fn delete_level(&mut self, level_idx: usize) { + if self.levels.len() <= 1 { + return; + } + if level_idx >= self.levels.len() { + return; + } + self.push_undo_snapshot(); + self.levels.remove(level_idx); + if self.settings.active_level_index >= self.levels.len() { + self.settings.active_level_index = self.levels.len() - 1; + } + self.reset_transient_state(); + } + + pub fn refresh_level(&mut self, level_idx: usize) { + if level_idx >= self.levels.len() { + return; + } + + let layout = &mut self.levels[level_idx]; + if !layout.packed_rooms { + let rooms = layout.rooms.clone(); + for corridor in &mut layout.corridors { + let start = rooms[corridor.start_room_id].center_cell(); + let end = rooms[corridor.end_room_id].center_cell(); + let blocked = layout::blocked_room_cells( + &rooms, + &[corridor.start_room_id, corridor.end_room_id], + ); + if let Some(path) = layout::shortest_path_cells( + start, + end, + self.settings.cols, + self.settings.rows, + &blocked, + ) { + corridor.path = path; + } + } + } + + let door_settings = door_settings_from_ui(&self.settings); + layout::apply_doors( + layout, + self.settings.seed, + door_settings, + self.settings.cols, + self.settings.rows, + ); + layout.doors.retain(|door| { + !crate::interact::door_edges_for(door, self.settings.cols, self.settings.rows) + .iter() + .any(|edge| self.suppressed_auto_door_edges.contains(edge)) + }); + + let window_settings = window_settings_from_ui(&self.settings); + layout::apply_windows( + layout, + self.settings.seed, + window_settings, + self.settings.cols, + self.settings.rows, + ); + } + + pub fn refresh_stairs(&mut self) { + let results = populate_stairs(self.levels.clone(), &self.settings); + self.levels = results.iter().map(|(layout, _)| layout.clone()).collect(); + + for (level_idx, (_, modified)) in results.into_iter().enumerate() { + if modified { + self.refresh_level(level_idx); + } + } + } + + pub fn refresh_doors(&mut self) { + if self.settings.active_level_index >= self.levels.len() { + return; + } + let layout = &mut self.levels[self.settings.active_level_index]; + + layout::apply_doors( + layout, + self.settings.seed, + door_settings_from_ui(&self.settings), + self.settings.cols, + self.settings.rows, + ); + layout.doors.retain(|door| { + !crate::interact::door_edges_for(door, self.settings.cols, self.settings.rows) + .iter() + .any(|edge| self.suppressed_auto_door_edges.contains(edge)) + }); + layout::apply_windows( + layout, + self.settings.seed, + window_settings_from_ui(&self.settings), + self.settings.cols, + self.settings.rows, + ); + } +} + +pub fn door_settings_from_ui(settings: &UiSettings) -> DoorSettings { + DoorSettings { + frequency_percent: settings.door_frequency_percent, + room_hallway_percent: settings.room_hallway_door_percent, + locked_percent: settings.locked_door_percent, + secret_percent: settings.secret_door_percent, + allow_middle_corridor_doors: settings.allow_middle_corridor_doors, + } +} + +pub fn window_settings_from_ui(settings: &UiSettings) -> WindowSettings { + WindowSettings { + enabled: settings.windows_enabled, + min_width: settings.min_window_width, + max_width: settings.max_window_width, + frequency_percent: settings.window_frequency_percent, + room_hallway_percent: settings.room_hallway_window_percent, + allow_internal_windows: settings.allow_internal_windows, + } +} diff --git a/src/interact.rs b/src/interact.rs new file mode 100644 index 0000000..3295ea8 --- /dev/null +++ b/src/interact.rs @@ -0,0 +1,2378 @@ +use crate::app::{DungeonApp, ManualDoorKind}; +use crate::layout::{ + self, blocked_room_cells, corridor_cells, rects_overlap, room_index_at_cell, rooms_overlap, + shortest_path_cells, +}; +use crate::ui::AddTool; +use eframe::egui; +use egui::{Color32, Stroke}; +use std::collections::HashSet; + +#[derive(Debug, Clone)] +pub struct RoomDragState { + pub room_idx: usize, + pub offset_x: f32, + pub offset_y: f32, +} + +#[derive(Debug, Clone)] +pub struct TextDragState { + pub text_idx: usize, +} + +#[derive(Debug, Clone)] +pub struct MarkerDragState { + pub kind: MarkerKind, + pub marker_idx: usize, + pub offset_x: f32, + pub offset_y: f32, +} + +#[derive(Debug, Clone)] +pub struct CorridorDragState { + pub corridor_index: usize, + pub original_path: Vec<(usize, usize)>, + pub original_cell: (usize, usize), +} + +#[derive(Debug, Clone)] +pub struct AddCorridorDrag { + pub start_cell: (usize, usize), + pub current_cell: (usize, usize), +} + +#[derive(Debug, Clone, Copy)] +pub enum ResizeCorner { + TopLeft, + TopRight, + BottomLeft, + BottomRight, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MarkerKind { + Start, + End, + Trap, + Monster, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HoverMarker { + Start(usize), + End(usize), + Trap(usize), + Monster(usize), +} + +#[derive(Debug, Clone)] +pub struct RoomResizeState { + pub room_idx: usize, + pub original_room: layout::Room, + pub resize_corner: ResizeCorner, +} + +#[derive(Debug, Clone)] +pub enum DragState { + Room(RoomDragState), + Text(TextDragState), + Marker(MarkerDragState), + Corridor(CorridorDragState), + Staircase(StaircaseDragState), +} + +#[derive(Debug, Clone)] +pub struct StaircaseDragState { + pub stair_idx: usize, + pub offset_x: f32, + pub offset_y: f32, + pub original_cell: (usize, usize), +} + +#[derive(Debug, Clone)] +pub struct TextResizeState { + pub text_idx: usize, + pub origin_cell: (usize, usize), + pub original_font_size: u16, +} + +#[derive(Debug, Clone)] +pub struct StaircaseResizeState { + pub stair_idx: usize, + pub original_stair: layout::Staircase, + pub resize_corner: ResizeCorner, +} + +#[derive(Debug, Clone)] +pub enum ResizeState { + Room(RoomResizeState), + Text(TextResizeState), + Staircase(StaircaseResizeState), +} + +pub struct GridGeometry { + pub rect: egui::Rect, + pub cell_size: f32, + pub cols: usize, + pub rows: usize, +} + +pub fn draw_grid( + painter: &egui::Painter, + area: egui::Rect, + cols: usize, + rows: usize, +) -> GridGeometry { + let cols = cols.max(1); + let rows = rows.max(1); + + let cell_size = (area.width() / cols as f32).min(area.height() / rows as f32); + let grid_width = cell_size * cols as f32; + let grid_height = cell_size * rows as f32; + + let grid_rect = egui::Rect::from_min_size( + egui::pos2( + area.center().x - (grid_width * 0.5), + area.center().y - (grid_height * 0.5), + ), + egui::vec2(grid_width, grid_height), + ); + + let stroke = Stroke::new(1.0, Color32::from_gray(160)); + painter.rect_stroke(grid_rect, 0.0, stroke, egui::StrokeKind::Middle); + + for col in 1..cols { + let x = grid_rect.left() + (col as f32 * cell_size); + painter.line_segment( + [ + egui::pos2(x, grid_rect.top()), + egui::pos2(x, grid_rect.bottom()), + ], + stroke, + ); + } + + for row in 1..rows { + let y = grid_rect.top() + (row as f32 * cell_size); + painter.line_segment( + [ + egui::pos2(grid_rect.left(), y), + egui::pos2(grid_rect.right(), y), + ], + stroke, + ); + } + + GridGeometry { + rect: grid_rect, + cell_size, + cols, + rows, + } +} + +pub fn handle_drag( + app: &mut DungeonApp, + ctx: &egui::Context, + response: &egui::Response, + geometry: &GridGeometry, +) { + if !ctx.input(|i| i.pointer.primary_down()) && !response.drag_started() { + return; + } + if app.settings.add_tool != AddTool::None { + return; + } + if response.drag_started() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some(text_idx) = text_at_pointer(app, pointer_pos, geometry) + { + app.push_undo_snapshot(); + app.drag_state = Some(DragState::Text(TextDragState { text_idx })); + } else if response.drag_started() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some(marker_drag) = marker_drag_at_pointer(app, pointer_pos, geometry) + { + app.push_undo_snapshot(); + app.drag_state = Some(DragState::Marker(marker_drag)); + } else if response.drag_started() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some(stair_drag) = stair_drag_at_pointer(app, pointer_pos, geometry) + { + app.push_undo_snapshot(); + app.drag_state = Some(DragState::Staircase(stair_drag)); + } else if response.drag_started() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some(corridor_drag) = corridor_drag_at_pointer(app, pointer_pos, geometry) + { + app.push_undo_snapshot(); + app.drag_state = Some(DragState::Corridor(corridor_drag)); + } else if response.drag_started() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((room_idx, offset_x, offset_y)) = room_at_pointer(app, pointer_pos, geometry) + { + app.push_undo_snapshot(); + app.drag_state = Some(DragState::Room(RoomDragState { + room_idx, + offset_x, + offset_y, + })); + } + + if response.dragged() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some(drag_state) = app.drag_state.clone() + { + match drag_state { + DragState::Room(drag) => drag_room_to_pointer(app, drag, pointer_pos, geometry), + DragState::Text(drag) => drag_text_to_pointer(app, drag, pointer_pos, geometry), + DragState::Marker(drag) => drag_marker_to_pointer(app, drag, pointer_pos, geometry), + DragState::Corridor(drag) => drag_corridor_to_pointer(app, drag, pointer_pos, geometry), + DragState::Staircase(drag) => drag_stair_to_pointer(app, drag, pointer_pos, geometry), + } + } + + if response.drag_stopped() || !response.ctx.input(|i| i.pointer.primary_down()) { + app.drag_state = None; + } +} + +pub fn handle_resize( + app: &mut DungeonApp, + ctx: &egui::Context, + response: &egui::Response, + geometry: &GridGeometry, +) -> bool { + if app.settings.add_tool != AddTool::None { + return false; + } + + if ctx.input(|i| i.pointer.secondary_pressed()) && app.resize_state.is_none() { + if let Some(pointer_pos) = response.interact_pointer_pos() { + if let Some(text_idx) = text_at_pointer(app, pointer_pos, geometry) { + if let Some(state) = start_text_resize(app, text_idx) { + app.push_undo_snapshot(); + app.resize_state = Some(ResizeState::Text(state)); + return true; + } + } else if let Some(stair_idx) = stair_at_pointer(app, pointer_pos, geometry) { + if let Some(state) = start_staircase_resize(app, stair_idx, pointer_pos, geometry) { + app.push_undo_snapshot(); + app.resize_state = Some(ResizeState::Staircase(state)); + return true; + } + } else if let Some(room_idx) = resize_room_at_pointer(app, pointer_pos, geometry) { + if let Some(state) = start_resize(app, room_idx, pointer_pos, geometry) { + app.push_undo_snapshot(); + app.resize_state = Some(ResizeState::Room(state)); + return true; + } + } + } + } + + if let Some(state) = app.resize_state.clone() { + if response.dragged_by(egui::PointerButton::Secondary) + && ctx.input(|i| i.pointer.secondary_down()) + && let Some(pointer_pos) = response.interact_pointer_pos() + { + if let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) { + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + match state { + ResizeState::Room(state) => resize_room(app, &state, cell), + ResizeState::Text(state) => resize_text(app, &state, cell), + ResizeState::Staircase(state) => resize_staircase(app, &state, cell), + } + } + } + + if ctx.input(|i| i.pointer.button_released(egui::PointerButton::Secondary)) { + app.resize_state = None; + } + return true; + } + + false +} + +pub fn handle_add_tool(app: &mut DungeonApp, response: &egui::Response, geometry: &GridGeometry) { + if app.settings.add_tool != AddTool::None && response.secondary_clicked() { + app.settings.add_tool = AddTool::None; + app.add_corridor_drag = None; + return; + } + + match app.settings.add_tool { + AddTool::Room => { + app.add_corridor_drag = None; + if response.clicked() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) + { + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + add_room_at_cell(app, cell); + } + } + AddTool::Corridor => { + if response.drag_started() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) + { + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + app.add_corridor_drag = Some(AddCorridorDrag { + start_cell: cell, + current_cell: cell, + }); + } + + if response.dragged() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) + && let Some(drag) = &mut app.add_corridor_drag + { + drag.current_cell = (grid_x.floor() as usize, grid_y.floor() as usize); + } + + if response.drag_stopped() { + if let Some(drag) = app.add_corridor_drag.take() { + add_corridor_between(app, drag.start_cell, drag.current_cell); + } + } + } + AddTool::Staircase => { + app.add_corridor_drag = None; + if response.clicked() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) + { + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + add_staircase_at_cell(app, cell); + } + } + AddTool::Archway | AddTool::Door | AddTool::LockedDoor | AddTool::SecretDoor => { + app.add_corridor_drag = None; + if response.clicked() + && let Some(pointer_pos) = response.interact_pointer_pos() + { + let kind = match app.settings.add_tool { + AddTool::Archway => ManualDoorKind::Archway, + AddTool::Door => ManualDoorKind::Regular, + AddTool::LockedDoor => ManualDoorKind::Locked, + AddTool::SecretDoor => ManualDoorKind::Secret, + _ => unreachable!(), + }; + add_door_at_pointer(app, pointer_pos, geometry, kind); + } + } + AddTool::Text => { + app.add_corridor_drag = None; + if response.clicked() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) + { + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + add_text_at_cell(app, cell); + } + } + AddTool::StartMarker + | AddTool::EndMarker + | AddTool::TrapMarker + | AddTool::MonsterMarker => { + app.add_corridor_drag = None; + if response.clicked() + && let Some(pointer_pos) = response.interact_pointer_pos() + && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) + { + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + let kind = match app.settings.add_tool { + AddTool::StartMarker => MarkerKind::Start, + AddTool::EndMarker => MarkerKind::End, + AddTool::TrapMarker => MarkerKind::Trap, + AddTool::MonsterMarker => MarkerKind::Monster, + _ => unreachable!(), + }; + add_marker_at_cell(app, cell, kind); + } + } + AddTool::None => { + app.add_corridor_drag = None; + } + } +} + +pub fn update_hover_targets(app: &mut DungeonApp, ctx: &egui::Context, geometry: &GridGeometry) { + if app.drag_state.is_some() || app.resize_state.is_some() || app.add_corridor_drag.is_some() { + return; + } + + let Some(pointer_pos) = ctx.pointer_hover_pos() else { + app.clear_hover_targets(); + return; + }; + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &app.levels[app.settings.active_level_index]; + + if let Some(edge) = door_edge_at_pointer(app, pointer_pos, geometry) { + let target = normalized_edge(edge.0, edge.1); + app.hover_door_idx = layout.doors.iter().position(|d| { + door_edges_for(d, app.settings.cols, app.settings.rows) + .iter() + .any(|e| normalized_edge(e.0, e.1) == target) + }); + if app.hover_door_idx.is_some() { + app.hover_room_idx = None; + app.hover_corridor_idx = None; + app.hover_text_idx = None; + app.hover_marker = None; + return; + } + } + + app.hover_text_idx = text_at_pointer(app, pointer_pos, geometry); + if app.hover_text_idx.is_some() { + app.hover_room_idx = None; + app.hover_corridor_idx = None; + app.hover_door_idx = None; + app.hover_marker = None; + return; + } + + app.hover_marker = marker_at_pointer(app, pointer_pos, geometry); + if app.hover_marker.is_some() { + app.hover_room_idx = None; + app.hover_corridor_idx = None; + app.hover_door_idx = None; + app.hover_stair_idx = None; + return; + } + + app.hover_stair_idx = stair_at_pointer(app, pointer_pos, geometry); + if app.hover_stair_idx.is_some() { + app.hover_room_idx = None; + app.hover_corridor_idx = None; + app.hover_door_idx = None; + app.hover_marker = None; + return; + } + + if let Some((room_idx, _, _)) = room_at_pointer(app, pointer_pos, geometry) { + app.hover_room_idx = Some(room_idx); + app.hover_corridor_idx = None; + app.hover_door_idx = None; + app.hover_marker = None; + } else { + app.hover_room_idx = None; + if let Some(corridor_drag) = corridor_drag_at_pointer(app, pointer_pos, geometry) { + app.hover_corridor_idx = Some(corridor_drag.corridor_index); + app.hover_door_idx = None; + app.hover_marker = None; + } else { + app.hover_corridor_idx = None; + app.hover_door_idx = None; + app.hover_marker = None; + } + } +} + +pub fn delete_at_pointer(app: &mut DungeonApp, pointer_pos: egui::Pos2, geometry: &GridGeometry) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + + if let Some(idx) = text_at_pointer(app, pointer_pos, geometry) { + app.push_undo_snapshot(); + let layout = &mut app.levels[app.settings.active_level_index]; + if idx < layout.text_labels.len() { + layout.text_labels.remove(idx); + } + return; + } + + if let Some(marker) = marker_at_pointer(app, pointer_pos, geometry) { + app.push_undo_snapshot(); + let layout = &mut app.levels[app.settings.active_level_index]; + match marker { + HoverMarker::Start(idx) => { + if idx < layout.start_markers.len() { + layout.start_markers.remove(idx); + } + } + HoverMarker::End(idx) => { + if idx < layout.end_markers.len() { + layout.end_markers.remove(idx); + } + } + HoverMarker::Trap(idx) => { + if idx < layout.trap_markers.len() { + layout.trap_markers.remove(idx); + } + } + HoverMarker::Monster(idx) => { + if idx < layout.monster_markers.len() { + layout.monster_markers.remove(idx); + } + } + } + return; + } + + if let Some(edge) = door_edge_at_pointer(app, pointer_pos, geometry) { + let target = normalized_edge(edge.0, edge.1); + let active_index = app.settings.active_level_index; + if let Some(idx) = app.levels[active_index].doors.iter().position(|d| { + door_edges_for(d, app.settings.cols, app.settings.rows) + .iter() + .any(|e| normalized_edge(e.0, e.1) == target) + }) { + app.push_undo_snapshot(); + let layout = &mut app.levels[app.settings.active_level_index]; + if !layout.doors[idx].manual { + for edge in door_edges_for(&layout.doors[idx], app.settings.cols, app.settings.rows) + { + app.suppressed_auto_door_edges.insert(edge); + } + layout.doors.remove(idx); + return; + } + + layout.doors.remove(idx); + return; + } + } + + if let Some(stair_idx) = stair_at_pointer(app, pointer_pos, geometry) { + let original_cell = { + let layout = &app.levels[app.settings.active_level_index]; + layout.stairs[stair_idx].cell + }; + + app.push_undo_snapshot(); + if app.settings.sync_stairs_across_levels { + for layout in &mut app.levels { + layout.stairs.retain(|s| s.cell != original_cell); + } + } else { + let current_idx = app.settings.active_level_index; + let level_indices = vec![ + Some(current_idx), + current_idx.checked_sub(1), + if current_idx + 1 < app.levels.len() { + Some(current_idx + 1) + } else { + None + }, + ]; + + for idx in level_indices.into_iter().flatten() { + app.levels[idx].stairs.retain(|s| s.cell != original_cell); + } + } + return; + } + + if let Some((room_idx, _, _)) = room_at_pointer(app, pointer_pos, geometry) { + app.push_undo_snapshot(); + delete_room(app, room_idx); + return; + } + + if let Some(corridor_drag) = corridor_drag_at_pointer(app, pointer_pos, geometry) { + let active_index = app.settings.active_level_index; + if corridor_drag.corridor_index < app.levels[active_index].corridors.len() { + app.push_undo_snapshot(); + let layout = &mut app.levels[app.settings.active_level_index]; + layout.corridors.remove(corridor_drag.corridor_index); + app.drag_state = None; + app.refresh_doors(); + } + } +} + +pub fn add_room_at_cell(app: &mut DungeonApp, cell: (usize, usize)) { + app.push_undo_snapshot(); + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + let width = app.settings.add_room_width.max(1); + let height = app.settings.add_room_height.max(1); + if cell.0 >= app.settings.cols || cell.1 >= app.settings.rows { + return; + } + let max_w = app.settings.cols.saturating_sub(cell.0); + let max_h = app.settings.rows.saturating_sub(cell.1); + let width = width.min(max_w); + let height = height.min(max_h); + if width == 0 || height == 0 { + return; + } + + let new_room = layout::Room { + x: cell.0, + y: cell.1, + width, + height, + }; + + if layout + .rooms + .iter() + .any(|room| rooms_overlap(&new_room, room)) + { + return; + } + + layout.rooms.push(new_room); + app.refresh_doors(); +} + +pub fn add_corridor_between(app: &mut DungeonApp, start: (usize, usize), end: (usize, usize)) { + app.push_undo_snapshot(); + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + if start == end { + return; + } + + let start_room = room_index_at_cell(&layout.rooms, start); + let end_room = room_index_at_cell(&layout.rooms, end); + let (Some(start_room), Some(end_room)) = (start_room, end_room) else { + return; + }; + + let blocked = blocked_room_cells(&layout.rooms, &[start_room, end_room]); + + let Some(path) = + shortest_path_cells(start, end, app.settings.cols, app.settings.rows, &blocked) + else { + return; + }; + + let next_id = layout + .corridors + .iter() + .map(|c| c.id) + .max() + .unwrap_or(0) + .wrapping_add(1); + let width = app.settings.add_corridor_width.max(1); + + layout.corridors.push(layout::Corridor { + id: next_id, + start_room_id: start_room, + end_room_id: end_room, + path, + width, + }); + app.refresh_doors(); +} + +pub fn add_text_at_cell(app: &mut DungeonApp, cell: (usize, usize)) { + app.push_undo_snapshot(); + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + if cell.0 >= app.settings.cols || cell.1 >= app.settings.rows { + return; + } + + let text = app.settings.add_text_value.trim().to_string(); + if text.is_empty() { + return; + } + + if let Some(existing) = layout + .text_labels + .iter_mut() + .find(|label| label.cell == cell) + { + existing.text = text; + return; + } + + layout.text_labels.push(layout::TextLabel { + cell, + text, + font_size: app.settings.add_text_font_size, + }); +} + +pub fn add_marker_at_cell(app: &mut DungeonApp, cell: (usize, usize), kind: MarkerKind) { + app.push_undo_snapshot(); + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + if cell.0 >= app.settings.cols || cell.1 >= app.settings.rows { + return; + } + + let size = app.settings.add_marker_size.max(1); + let origin = centered_marker_origin(cell, size, app.settings.cols, app.settings.rows); + let marker = layout::AreaMarker { cell: origin, size }; + + match kind { + MarkerKind::Start => layout.start_markers.push(marker), + MarkerKind::End => layout.end_markers.push(marker), + MarkerKind::Trap => layout.trap_markers.push(marker), + MarkerKind::Monster => layout.monster_markers.push(marker), + } +} + +pub fn add_staircase_at_cell(app: &mut DungeonApp, cell: (usize, usize)) { + app.push_undo_snapshot(); + if app.settings.active_level_index >= app.levels.len() { + return; + } + + if cell.0 >= app.settings.cols || cell.1 >= app.settings.rows { + return; + } + + let width = app.settings.add_stair_width.max(1); + let height = app.settings.add_stair_height.max(1); + let max_x = app.settings.cols.saturating_sub(width); + let max_y = app.settings.rows.saturating_sub(height); + let origin_x = cell.0.min(max_x); + let origin_y = cell.1.min(max_y); + + let stair = layout::Staircase { + cell: (origin_x, origin_y), + width, + height, + }; + + if app.settings.sync_stairs_across_levels { + for layout in &mut app.levels { + layout.stairs.push(stair.clone()); + } + } else { + let active_idx = app.settings.active_level_index; + let layout = &mut app.levels[active_idx]; + layout.stairs.push(stair.clone()); + + if active_idx + 1 < app.levels.len() { + let above_layout = &mut app.levels[active_idx + 1]; + above_layout.stairs.push(stair); + } else if active_idx > 0 { + let below_layout = &mut app.levels[active_idx - 1]; + below_layout.stairs.push(stair); + } + } +} + +pub fn delete_room(app: &mut DungeonApp, room_idx: usize) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + if room_idx >= layout.rooms.len() { + return; + } + layout.rooms.remove(room_idx); + layout + .corridors + .retain(|c| c.start_room_id != room_idx && c.end_room_id != room_idx); + for corridor in &mut layout.corridors { + if corridor.start_room_id > room_idx { + corridor.start_room_id -= 1; + } + if corridor.end_room_id > room_idx { + corridor.end_room_id -= 1; + } + } + app.drag_state = None; + app.refresh_doors(); +} + +pub fn add_door_at_pointer( + app: &mut DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, + kind: ManualDoorKind, +) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + + let Some(edge) = door_edge_at_pointer(app, pointer_pos, geometry) else { + return; + }; + + app.push_undo_snapshot(); + let layout = &mut app.levels[app.settings.active_level_index]; + + let target = normalized_edge(edge.0, edge.1); + if let Some(idx) = layout.doors.iter().position(|d| { + door_edges_for(d, app.settings.cols, app.settings.rows) + .iter() + .any(|e| normalized_edge(e.0, e.1) == target) + }) { + let edges = door_edges_for(&layout.doors[idx], app.settings.cols, app.settings.rows); + let mut spawned = Vec::new(); + for edge in edges { + if normalized_edge(edge.0, edge.1) == target { + continue; + } + if layout + .doors + .iter() + .any(|d| normalized_edge(d.from, d.to) == normalized_edge(edge.0, edge.1)) + { + continue; + } + spawned.push(layout::Door { + from: edge.0, + to: edge.1, + width: 1, + span_width: false, + locked: false, + archway: true, + secret: false, + manual: true, + }); + } + let door = &mut layout.doors[idx]; + door.locked = kind == ManualDoorKind::Locked; + door.archway = kind == ManualDoorKind::Archway; + door.secret = kind == ManualDoorKind::Secret; + door.width = 1; + door.span_width = false; + door.manual = true; + for edge in door_edges_for(door, app.settings.cols, app.settings.rows) { + app.suppressed_auto_door_edges.remove(&edge); + } + layout.doors.extend(spawned); + return; + } + + let corridor_cells = corridor_cells(layout, app.settings.cols, app.settings.rows); + let a_room = room_index_at_cell(&layout.rooms, edge.0); + let b_room = room_index_at_cell(&layout.rooms, edge.1); + let a_in_corridor = corridor_cells.contains(&edge.0); + let b_in_corridor = corridor_cells.contains(&edge.1); + + if !manual_door_edge_allowed(a_room, b_room, a_in_corridor, b_in_corridor) { + return; + } + + layout.doors.push(layout::Door { + from: edge.0, + to: edge.1, + width: 1, + span_width: false, + locked: kind == ManualDoorKind::Locked, + archway: kind == ManualDoorKind::Archway, + secret: kind == ManualDoorKind::Secret, + manual: true, + }); + app.suppressed_auto_door_edges.remove(&target); +} + +pub fn door_edge_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option<((usize, usize), (usize, usize))> { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let cell_x = grid_x.floor() as isize; + let cell_y = grid_y.floor() as isize; + if cell_x < 0 + || cell_y < 0 + || cell_x >= app.settings.cols as isize + || cell_y >= app.settings.rows as isize + { + return None; + } + + let fx = grid_x - grid_x.floor(); + let fy = grid_y - grid_y.floor(); + let left = fx; + let right = 1.0 - fx; + let top = fy; + let bottom = 1.0 - fy; + + let mut candidates = [ + (left, (cell_x - 1, cell_y), (cell_x, cell_y)), + (right, (cell_x + 1, cell_y), (cell_x, cell_y)), + (top, (cell_x, cell_y - 1), (cell_x, cell_y)), + (bottom, (cell_x, cell_y + 1), (cell_x, cell_y)), + ]; + candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap()); + let (dist, a, b) = candidates[0]; + if dist > 0.2 { + return None; + } + + let (ax, ay) = a; + let (bx, by) = b; + if ax < 0 + || ay < 0 + || bx < 0 + || by < 0 + || ax >= app.settings.cols as isize + || bx >= app.settings.cols as isize + || ay >= app.settings.rows as isize + || by >= app.settings.rows as isize + { + return None; + } + + Some(((ax as usize, ay as usize), (bx as usize, by as usize))) +} + +pub fn room_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option<(usize, f32, f32)> { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + for (room_idx, room) in layout.rooms.iter().enumerate().rev() { + let room_x = room.x as f32; + let room_y = room.y as f32; + let room_right = room_x + room.width as f32; + let room_bottom = room_y + room.height as f32; + + if grid_x >= room_x && grid_x < room_right && grid_y >= room_y && grid_y < room_bottom { + return Some((room_idx, grid_x - room_x, grid_y - room_y)); + } + } + + None +} + +pub fn drag_room_to_pointer( + app: &mut DungeonApp, + drag: RoomDragState, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) { + let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { + return; + }; + + if app.settings.active_level_index >= app.levels.len() { + return; + } + + if drag.room_idx >= app.levels[app.settings.active_level_index].rooms.len() { + app.drag_state = None; + return; + } + + let (width, height, old_x, old_y) = { + let room = &app.levels[app.settings.active_level_index].rooms[drag.room_idx]; + (room.width, room.height, room.x, room.y) + }; + let max_x = app.settings.cols.saturating_sub(width); + let max_y = app.settings.rows.saturating_sub(height); + + let target_x = (grid_x - drag.offset_x).floor().max(0.0) as usize; + let target_y = (grid_y - drag.offset_y).floor().max(0.0) as usize; + let new_x = target_x.min(max_x); + let new_y = target_y.min(max_y); + + if new_x == old_x && new_y == old_y { + return; + } + + app.levels[app.settings.active_level_index].rooms[drag.room_idx].x = new_x; + app.levels[app.settings.active_level_index].rooms[drag.room_idx].y = new_y; + reroute_corridors_for_room(app, drag.room_idx); + app.refresh_doors(); +} + +pub fn drag_text_to_pointer( + app: &mut DungeonApp, + drag: TextDragState, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) { + let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { + return; + }; + + if app.settings.active_level_index >= app.levels.len() { + return; + } + + if drag.text_idx + >= app.levels[app.settings.active_level_index] + .text_labels + .len() + { + app.drag_state = None; + return; + } + + let target = ( + (grid_x.floor().max(0.0) as usize).min(app.settings.cols.saturating_sub(1)), + (grid_y.floor().max(0.0) as usize).min(app.settings.rows.saturating_sub(1)), + ); + + app.levels[app.settings.active_level_index].text_labels[drag.text_idx].cell = target; +} + +pub fn reroute_corridors_for_room(app: &mut DungeonApp, room_idx: usize) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + if layout.packed_rooms { + return; + } + + for corridor in &mut layout.corridors { + if corridor.start_room_id != room_idx && corridor.end_room_id != room_idx { + continue; + } + + let start = layout.rooms[corridor.start_room_id].center_cell(); + let end = layout.rooms[corridor.end_room_id].center_cell(); + let blocked = blocked_room_cells( + &layout.rooms, + &[corridor.start_room_id, corridor.end_room_id], + ); + if let Some(path) = + shortest_path_cells(start, end, app.settings.cols, app.settings.rows, &blocked) + { + corridor.path = path; + } + } +} + +pub fn corridor_drag_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let clicked = (grid_x.floor() as usize, grid_y.floor() as usize); + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + let corridor_index = layout + .corridors + .iter() + .position(|corridor| corridor.path.contains(&clicked))?; + + Some(CorridorDragState { + corridor_index, + original_path: layout.corridors[corridor_index].path.clone(), + original_cell: clicked, + }) +} + +pub fn drag_corridor_to_pointer( + app: &mut DungeonApp, + drag: CorridorDragState, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) { + let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { + return; + }; + + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + if drag.corridor_index >= layout.corridors.len() { + app.drag_state = None; + return; + } + let corridor = &layout.corridors[drag.corridor_index]; + + let target = ( + (grid_x.floor().max(0.0) as usize).min(app.settings.cols.saturating_sub(1)), + (grid_y.floor().max(0.0) as usize).min(app.settings.rows.saturating_sub(1)), + ); + + let Some(new_path) = reroute_path_through_cell( + &drag.original_path, + drag.original_cell, + target, + app.settings.cols, + app.settings.rows, + &blocked_room_cells( + &layout.rooms, + &[corridor.start_room_id, corridor.end_room_id], + ), + ) else { + return; + }; + + layout.corridors[drag.corridor_index].path = new_path; + app.refresh_doors(); +} + +pub fn drag_marker_to_pointer( + app: &mut DungeonApp, + drag: MarkerDragState, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) { + let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { + return; + }; + + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + let markers = match drag.kind { + MarkerKind::Start => &mut layout.start_markers, + MarkerKind::End => &mut layout.end_markers, + MarkerKind::Trap => &mut layout.trap_markers, + MarkerKind::Monster => &mut layout.monster_markers, + }; + let Some(marker) = markers.get_mut(drag.marker_idx) else { + app.drag_state = None; + return; + }; + + let max_x = app.settings.cols.saturating_sub(marker.size); + let max_y = app.settings.rows.saturating_sub(marker.size); + let target_x = (grid_x - drag.offset_x).floor().max(0.0) as usize; + let target_y = (grid_y - drag.offset_y).floor().max(0.0) as usize; + marker.cell = (target_x.min(max_x), target_y.min(max_y)); +} + +pub fn drag_stair_to_pointer( + app: &mut DungeonApp, + drag: StaircaseDragState, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) { + let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { + return; + }; + + if app.settings.active_level_index >= app.levels.len() { + return; + } + + let (stair_width, stair_height) = { + let layout = &app.levels[app.settings.active_level_index]; + let Some(stair) = layout.stairs.get(drag.stair_idx) else { + return; + }; + (stair.width, stair.height) + }; + + let max_x = app.settings.cols.saturating_sub(stair_width); + let max_y = app.settings.rows.saturating_sub(stair_height); + let target_x = (grid_x - drag.offset_x).floor().max(0.0) as usize; + let target_y = (grid_y - drag.offset_y).floor().max(0.0) as usize; + let new_cell = (target_x.min(max_x), target_y.min(max_y)); + + if app.settings.sync_stairs_across_levels { + for layout in &mut app.levels { + for stair in &mut layout.stairs { + if stair.cell == drag.original_cell { + stair.cell = new_cell; + } + } + } + } else { + let current_idx = app.settings.active_level_index; + let level_indices = vec![ + Some(current_idx), + current_idx.checked_sub(1), + if current_idx + 1 < app.levels.len() { + Some(current_idx + 1) + } else { + None + }, + ]; + + for idx in level_indices.into_iter().flatten() { + let layout = &mut app.levels[idx]; + for stair in &mut layout.stairs { + if stair.cell == drag.original_cell { + stair.cell = new_cell; + } + } + } + } + + if let Some(DragState::Staircase(ref mut d)) = app.drag_state { + d.original_cell = new_cell; + } +} + +pub fn text_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let clicked = (grid_x.floor() as usize, grid_y.floor() as usize); + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + layout + .text_labels + .iter() + .position(|label| label.cell == clicked) +} + +pub fn marker_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let clicked = (grid_x.floor() as usize, grid_y.floor() as usize); + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + layout + .start_markers + .iter() + .enumerate() + .find(|(_, marker)| marker_contains_cell(Some(marker), clicked)) + .map(|(idx, _)| HoverMarker::Start(idx)) + .or_else(|| { + layout + .end_markers + .iter() + .enumerate() + .find(|(_, marker)| marker_contains_cell(Some(marker), clicked)) + .map(|(idx, _)| HoverMarker::End(idx)) + }) + .or_else(|| { + layout + .trap_markers + .iter() + .enumerate() + .find(|(_, marker)| marker_contains_cell(Some(marker), clicked)) + .map(|(idx, _)| HoverMarker::Trap(idx)) + }) + .or_else(|| { + layout + .monster_markers + .iter() + .enumerate() + .find(|(_, marker)| marker_contains_cell(Some(marker), clicked)) + .map(|(idx, _)| HoverMarker::Monster(idx)) + }) +} + +pub fn marker_drag_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + match marker_at_pointer(app, pointer_pos, geometry)? { + HoverMarker::Start(marker_idx) => { + let marker = layout.start_markers.get(marker_idx)?; + Some(MarkerDragState { + kind: MarkerKind::Start, + marker_idx, + offset_x: grid_x - marker.cell.0 as f32, + offset_y: grid_y - marker.cell.1 as f32, + }) + } + HoverMarker::End(marker_idx) => { + let marker = layout.end_markers.get(marker_idx)?; + Some(MarkerDragState { + kind: MarkerKind::End, + marker_idx, + offset_x: grid_x - marker.cell.0 as f32, + offset_y: grid_y - marker.cell.1 as f32, + }) + } + HoverMarker::Trap(marker_idx) => { + let marker = layout.trap_markers.get(marker_idx)?; + Some(MarkerDragState { + kind: MarkerKind::Trap, + marker_idx, + offset_x: grid_x - marker.cell.0 as f32, + offset_y: grid_y - marker.cell.1 as f32, + }) + } + HoverMarker::Monster(marker_idx) => { + let marker = layout.monster_markers.get(marker_idx)?; + Some(MarkerDragState { + kind: MarkerKind::Monster, + marker_idx, + offset_x: grid_x - marker.cell.0 as f32, + offset_y: grid_y - marker.cell.1 as f32, + }) + } + } +} + +pub fn stair_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let clicked = (grid_x.floor() as usize, grid_y.floor() as usize); + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + layout.stairs.iter().position(|stair| { + clicked.0 >= stair.cell.0 + && clicked.0 < stair.cell.0 + stair.width + && clicked.1 >= stair.cell.1 + && clicked.1 < stair.cell.1 + stair.height + }) +} + +pub fn stair_drag_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let stair_idx = stair_at_pointer(app, pointer_pos, geometry)?; + let layout = &app.levels[app.settings.active_level_index]; + let stair = layout.stairs.get(stair_idx)?; + + Some(StaircaseDragState { + stair_idx, + offset_x: grid_x - stair.cell.0 as f32, + offset_y: grid_y - stair.cell.1 as f32, + original_cell: stair.cell, + }) +} + +pub fn start_text_resize(app: &DungeonApp, text_idx: usize) -> Option { + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + let label = layout.text_labels.get(text_idx)?; + Some(TextResizeState { + text_idx, + origin_cell: label.cell, + original_font_size: label.font_size, + }) +} + +pub fn resize_room_at_pointer( + app: &DungeonApp, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + if let Some((room_idx, _, _)) = room_at_pointer(app, pointer_pos, geometry) { + return Some(room_idx); + } + + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let clicked = ( + grid_x.floor().max(0.0) as usize, + grid_y.floor().max(0.0) as usize, + ); + + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + for (room_idx, room) in layout.rooms.iter().enumerate().rev() { + if resize_hit_cells(room, room_idx, &layout.rooms, geometry.cols, geometry.rows) + .contains(&clicked) + { + return Some(room_idx); + } + } + + None +} + +pub fn start_resize( + app: &DungeonApp, + room_idx: usize, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + let original_room = layout.rooms.get(room_idx)?.clone(); + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let center_x = original_room.x as f32 + original_room.width as f32 * 0.5; + let center_y = original_room.y as f32 + original_room.height as f32 * 0.5; + let pointer_x = grid_x; + let pointer_y = grid_y; + + let resize_corner = match (pointer_x >= center_x, pointer_y >= center_y) { + (false, false) => ResizeCorner::TopLeft, + (true, false) => ResizeCorner::TopRight, + (false, true) => ResizeCorner::BottomLeft, + (true, true) => ResizeCorner::BottomRight, + }; + + Some(RoomResizeState { + room_idx, + original_room, + resize_corner, + }) +} + +pub fn start_staircase_resize( + app: &DungeonApp, + stair_idx: usize, + pointer_pos: egui::Pos2, + geometry: &GridGeometry, +) -> Option { + if app.settings.active_level_index >= app.levels.len() { + return None; + } + let layout = &app.levels[app.settings.active_level_index]; + + let original_stair = layout.stairs.get(stair_idx)?.clone(); + let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; + let center_x = original_stair.cell.0 as f32 + original_stair.width as f32 * 0.5; + let center_y = original_stair.cell.1 as f32 + original_stair.height as f32 * 0.5; + let pointer_x = grid_x; + let pointer_y = grid_y; + + let resize_corner = match (pointer_x >= center_x, pointer_y >= center_y) { + (false, false) => ResizeCorner::TopLeft, + (true, false) => ResizeCorner::TopRight, + (false, true) => ResizeCorner::BottomLeft, + (true, true) => ResizeCorner::BottomRight, + }; + + Some(StaircaseResizeState { + stair_idx, + original_stair, + resize_corner, + }) +} + +pub fn resize_room(app: &mut DungeonApp, state: &RoomResizeState, target_cell: (usize, usize)) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + let cols = app.settings.cols.max(1); + let rows = app.settings.rows.max(1); + + let tx = target_cell.0.min(cols - 1) as isize; + let ty = target_cell.1.min(rows - 1) as isize; + let original = &state.original_room; + + let left = original.x as isize; + let top = original.y as isize; + let right = (original.x + original.width - 1) as isize; + let bottom = (original.y + original.height - 1) as isize; + let (x0, x1, y0, y1) = match state.resize_corner { + ResizeCorner::TopLeft => (tx.min(right), right, ty.min(bottom), bottom), + ResizeCorner::TopRight => (left, tx.max(left), ty.min(bottom), bottom), + ResizeCorner::BottomLeft => (tx.min(right), right, top, ty.max(top)), + ResizeCorner::BottomRight => (left, tx.max(left), top, ty.max(top)), + }; + + let new_room = layout::Room { + x: x0 as usize, + y: y0 as usize, + width: (x1 - x0 + 1) as usize, + height: (y1 - y0 + 1) as usize, + }; + + if new_room.width == 0 || new_room.height == 0 { + return; + } + + if new_room.x + new_room.width > cols || new_room.y + new_room.height > rows { + return; + } + + if layout + .rooms + .iter() + .enumerate() + .any(|(idx, room)| idx != state.room_idx && rooms_overlap(&new_room, room)) + { + return; + } + + if let Some(room) = layout.rooms.get_mut(state.room_idx) { + room.x = new_room.x; + room.y = new_room.y; + room.width = new_room.width; + room.height = new_room.height; + reroute_corridors_for_room(app, state.room_idx); + app.refresh_doors(); + } +} + +pub fn resize_text(app: &mut DungeonApp, state: &TextResizeState, target_cell: (usize, usize)) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &mut app.levels[app.settings.active_level_index]; + + let Some(label) = layout.text_labels.get_mut(state.text_idx) else { + app.resize_state = None; + return; + }; + + let dx = target_cell.0 as isize - state.origin_cell.0 as isize; + let dy = target_cell.1 as isize - state.origin_cell.1 as isize; + let delta_cells = if dx.abs() >= dy.abs() { dx } else { -dy }; + let new_size = (state.original_font_size as isize + (delta_cells * 4)).clamp(8, 96); + label.font_size = new_size as u16; +} + +pub fn resize_staircase( + app: &mut DungeonApp, + state: &StaircaseResizeState, + target_cell: (usize, usize), +) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + + let cols = app.settings.cols.max(1); + let rows = app.settings.rows.max(1); + + let tx = target_cell.0.min(cols - 1) as isize; + let ty = target_cell.1.min(rows - 1) as isize; + let original = &state.original_stair; + + let left = original.cell.0 as isize; + let top = original.cell.1 as isize; + let right = (original.cell.0 + original.width - 1) as isize; + let bottom = (original.cell.1 + original.height - 1) as isize; + + let (x0, x1, y0, y1) = match state.resize_corner { + ResizeCorner::TopLeft => (tx.min(right), right, ty.min(bottom), bottom), + ResizeCorner::TopRight => (left, tx.max(left), ty.min(bottom), bottom), + ResizeCorner::BottomLeft => (tx.min(right), right, top, ty.max(top)), + ResizeCorner::BottomRight => (left, tx.max(left), top, ty.max(top)), + }; + + let new_stair_cell = (x0 as usize, y0 as usize); + let new_stair_width = (x1 - x0 + 1) as usize; + let new_stair_height = (y1 - y0 + 1) as usize; + + if new_stair_width == 0 || new_stair_height == 0 { + return; + } + + let active_layout = &app.levels[app.settings.active_level_index]; + if active_layout.stairs.iter().enumerate().any(|(idx, stair)| { + idx != state.stair_idx + && rects_overlap( + new_stair_cell.0, + new_stair_cell.1, + new_stair_width, + new_stair_height, + stair.cell.0, + stair.cell.1, + stair.width, + stair.height, + ) + }) { + return; + } + + if app.settings.sync_stairs_across_levels { + for layout in &mut app.levels { + for stair in &mut layout.stairs { + if stair.cell == state.original_stair.cell { + stair.cell = new_stair_cell; + stair.width = new_stair_width; + stair.height = new_stair_height; + } + } + } + } else { + let current_idx = app.settings.active_level_index; + let level_indices = vec![ + Some(current_idx), + current_idx.checked_sub(1), + if current_idx + 1 < app.levels.len() { + Some(current_idx + 1) + } else { + None + }, + ]; + + for idx in level_indices.into_iter().flatten() { + let layout = &mut app.levels[idx]; + for stair in &mut layout.stairs { + if stair.cell == state.original_stair.cell { + stair.cell = new_stair_cell; + stair.width = new_stair_width; + stair.height = new_stair_height; + } + } + } + } + + if let Some(ResizeState::Staircase(ref mut s)) = app.resize_state { + s.original_stair.cell = new_stair_cell; + s.original_stair.width = new_stair_width; + s.original_stair.height = new_stair_height; + } +} + +pub fn draw_add_overlay(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) { + if app.settings.add_tool != AddTool::Corridor { + return; + } + let Some(drag) = &app.add_corridor_drag else { + return; + }; + + let start = cell_center(geometry, drag.start_cell.0, drag.start_cell.1); + let end = cell_center(geometry, drag.current_cell.0, drag.current_cell.1); + let stroke = Stroke::new(2.0, Color32::WHITE); + painter.circle_filled(start, 3.0, Color32::WHITE); + crate::rendering::draw_dashed_line(painter, start, end, stroke, 8.0, 6.0); +} + +pub fn draw_add_tool_ghost(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) { + let tool = app.settings.add_tool; + if tool == AddTool::None || tool == AddTool::Corridor { + return; + } + + let Some(pointer_pos) = painter.ctx().pointer_hover_pos() else { + return; + }; + let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { + return; + }; + let cell = (grid_x.floor() as usize, grid_y.floor() as usize); + + let ghost_alpha = 0.35; + + match tool { + AddTool::Room => { + let width = app.settings.add_room_width.max(1); + let height = app.settings.add_room_height.max(1); + let max_x = app.settings.cols.saturating_sub(cell.0); + let max_y = app.settings.rows.saturating_sub(cell.1); + let w = width.min(max_x).max(1); + let h = height.min(max_y).max(1); + + let left = geometry.rect.left() + cell.0 as f32 * geometry.cell_size; + let top = geometry.rect.top() + cell.1 as f32 * geometry.cell_size; + let right = left + w as f32 * geometry.cell_size; + let bottom = top + h as f32 * geometry.cell_size; + let rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); + + let fill = Color32::from_rgb(70, 120, 160).gamma_multiply(ghost_alpha); + painter.rect_filled(rect, 4.0, fill); + painter.rect_stroke( + rect, + 4.0, + Stroke::new( + 1.5, + Color32::from_rgb(150, 200, 240).gamma_multiply(ghost_alpha + 0.3), + ), + egui::StrokeKind::Middle, + ); + } + AddTool::Text => { + let text = app.settings.add_text_value.trim(); + if text.is_empty() { + return; + } + let font_size = app.settings.add_text_font_size.max(8); + let rect = crate::rendering::text_label_rect( + geometry, + &layout::TextLabel { + cell, + text: text.to_string(), + font_size, + }, + ); + + let fill = Color32::from_rgb(50, 50, 70).gamma_multiply(ghost_alpha); + painter.rect_filled(rect.expand(4.0), 4.0, fill); + painter.rect_stroke( + rect.expand(4.0), + 4.0, + Stroke::new( + 1.0, + Color32::from_rgb(180, 180, 220).gamma_multiply(ghost_alpha + 0.2), + ), + egui::StrokeKind::Middle, + ); + + let color = Color32::WHITE.gamma_multiply(ghost_alpha + 0.1); + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(font_size as f32), + color, + ); + } + AddTool::StartMarker + | AddTool::EndMarker + | AddTool::TrapMarker + | AddTool::MonsterMarker => { + let size = app.settings.add_marker_size.max(1); + let origin = centered_marker_origin(cell, size, app.settings.cols, app.settings.rows); + + let rect = marker_rect(&layout::AreaMarker { cell: origin, size }, geometry); + + let base_color = match tool { + AddTool::StartMarker => Color32::from_rgb(60, 220, 200), + AddTool::EndMarker => Color32::from_rgb(240, 90, 90), + AddTool::TrapMarker => Color32::from_rgb(150, 80, 230), + AddTool::MonsterMarker => Color32::from_rgb(200, 50, 50), + _ => Color32::WHITE, + }; + + let fill = base_color.gamma_multiply(ghost_alpha); + painter.rect_filled(rect, 4.0, fill); + painter.rect_stroke( + rect, + 4.0, + Stroke::new( + 1.5, + Color32::from_rgb(255, 255, 255).gamma_multiply(ghost_alpha + 0.2), + ), + egui::StrokeKind::Middle, + ); + + let label = match tool { + AddTool::StartMarker => "S", + AddTool::EndMarker => "E", + AddTool::TrapMarker => "T", + AddTool::MonsterMarker => "M", + _ => "?", + }; + let text_color = Color32::WHITE.gamma_multiply(ghost_alpha + 0.15); + let font_size = (geometry.cell_size * size as f32 * 0.5).clamp(14.0, 72.0); + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(font_size), + text_color, + ); + } + AddTool::Staircase => { + let width = app.settings.add_stair_width.max(1); + let height = app.settings.add_stair_height.max(1); + let max_x = app.settings.cols.saturating_sub(cell.0); + let max_y = app.settings.rows.saturating_sub(cell.1); + let w = width.min(max_x).max(1); + let h = height.min(max_y).max(1); + + let left = geometry.rect.left() + cell.0 as f32 * geometry.cell_size; + let top = geometry.rect.top() + cell.1 as f32 * geometry.cell_size; + let right = left + w as f32 * geometry.cell_size; + let bottom = top + h as f32 * geometry.cell_size; + let rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); + + let fill = Color32::from_rgb(200, 150, 50).gamma_multiply(ghost_alpha); + painter.rect_filled(rect, 4.0, fill); + painter.rect_stroke( + rect, + 4.0, + Stroke::new( + 1.5, + Color32::from_rgb(220, 180, 80).gamma_multiply(ghost_alpha + 0.2), + ), + egui::StrokeKind::Middle, + ); + + let steps = (h as f32 * 2.0).round() as usize; + let step_height = (rect.height() / (steps as f32)).max(1.0); + let line_color = Color32::from_rgb(150, 100, 30).gamma_multiply(ghost_alpha + 0.15); + for i in 0..steps { + let y = rect.top() + (i as f32 * step_height) + step_height * 0.5; + painter.line_segment( + [ + egui::pos2(rect.left() + 2.0, y), + egui::pos2(rect.right() - 2.0, y), + ], + Stroke::new(1.5, line_color), + ); + } + } + AddTool::Archway | AddTool::Door | AddTool::LockedDoor | AddTool::SecretDoor => { + if let Some((edge_a, edge_b)) = door_edge_at_pointer(app, pointer_pos, geometry) { + let door_width_cells = 1; + let style = match tool { + AddTool::Archway => crate::rendering::DoorLineStyle::Dotted, + AddTool::Door => crate::rendering::DoorLineStyle::LongDash, + AddTool::LockedDoor => crate::rendering::DoorLineStyle::ShortDash, + AddTool::SecretDoor => crate::rendering::DoorLineStyle::DashDotDot, + _ => crate::rendering::DoorLineStyle::Solid, + }; + let color = match tool { + AddTool::Archway => Color32::from_rgb(230, 140, 60), + AddTool::Door => Color32::from_rgb(80, 200, 120), + AddTool::LockedDoor => Color32::from_rgb(220, 70, 70), + AddTool::SecretDoor => Color32::from_rgb(170, 80, 170), + _ => Color32::WHITE, + }; + let ghost_color = color.gamma_multiply(ghost_alpha); + crate::rendering::draw_door_line( + painter, + geometry, + edge_a, + edge_b, + door_width_cells, + Stroke::new((geometry.cell_size / 5.0).max(1.0), ghost_color), + style, + ); + } + } + AddTool::None | AddTool::Corridor => {} + } +} + +pub fn draw_resize_overlay(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) { + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &app.levels[app.settings.active_level_index]; + + if let Some(state) = &app.resize_state { + match state { + ResizeState::Text(state) => { + if let Some(label) = layout.text_labels.get(state.text_idx) { + let rect = crate::rendering::text_label_rect(geometry, label).expand(3.0); + painter.rect_stroke( + rect, + 0.0, + Stroke::new(2.0, Color32::from_gray(180)), + egui::StrokeKind::Middle, + ); + } + } + ResizeState::Room(state) => { + if let Some(room) = layout.rooms.get(state.room_idx) { + draw_room_resize_visuals(painter, geometry, room, true); + } + } + ResizeState::Staircase(state) => { + if let Some(stair) = layout.stairs.get(state.stair_idx) { + draw_staircase_resize_visuals(painter, geometry, stair, true); + } + } + } + return; + } + + if let Some(text_idx) = app.hover_text_idx { + if let Some(label) = layout.text_labels.get(text_idx) { + let rect = crate::rendering::text_label_rect(geometry, label).expand(3.0); + painter.rect_stroke( + rect, + 0.0, + Stroke::new(2.0, Color32::from_gray(180)), + egui::StrokeKind::Middle, + ); + } + } else if let Some(room_idx) = app.hover_room_idx { + if let Some(room) = layout.rooms.get(room_idx) { + draw_room_resize_visuals(painter, geometry, room, false); + } + } else if let Some(stair_idx) = app.hover_stair_idx { + if let Some(stair) = layout.stairs.get(stair_idx) { + draw_staircase_resize_visuals(painter, geometry, stair, false); + } + } +} + +pub fn draw_room_resize_visuals( + painter: &egui::Painter, + geometry: &GridGeometry, + room: &layout::Room, + show_handles: bool, +) { + let color = Color32::from_gray(170); + let stroke = Stroke::new(1.5, color); + + let left = geometry.rect.left() + room.x as f32 * geometry.cell_size; + let top = geometry.rect.top() + room.y as f32 * geometry.cell_size; + let right = left + room.width as f32 * geometry.cell_size; + let bottom = top + room.height as f32 * geometry.cell_size; + + let tl = egui::pos2(left, top); + let tr = egui::pos2(right, top); + let br = egui::pos2(right, bottom); + let bl = egui::pos2(left, bottom); + + crate::rendering::draw_dashed_line(painter, tl, tr, stroke, 6.0, 6.0); + crate::rendering::draw_dashed_line(painter, tr, br, stroke, 6.0, 6.0); + crate::rendering::draw_dashed_line(painter, br, bl, stroke, 6.0, 6.0); + crate::rendering::draw_dashed_line(painter, bl, tl, stroke, 6.0, 6.0); + + let font = egui::FontId::proportional(12.0); + let center_top = egui::pos2((left + right) * 0.5, top - 10.0); + let center_left = egui::pos2(left - 12.0, (top + bottom) * 0.5); + painter.text( + center_top, + egui::Align2::CENTER_CENTER, + "<->", + font.clone(), + color, + ); + painter.text(center_left, egui::Align2::CENTER_CENTER, "^v", font, color); + + if show_handles { + draw_resize_handle(painter, tl, ResizeCorner::TopLeft, color); + draw_resize_handle(painter, tr, ResizeCorner::TopRight, color); + draw_resize_handle(painter, br, ResizeCorner::BottomRight, color); + draw_resize_handle(painter, bl, ResizeCorner::BottomLeft, color); + } +} + +pub fn draw_staircase_resize_visuals( + painter: &egui::Painter, + geometry: &GridGeometry, + stair: &layout::Staircase, + show_handles: bool, +) { + let color = Color32::from_gray(170); + let stroke = Stroke::new(1.5, color); + + let left = geometry.rect.left() + stair.cell.0 as f32 * geometry.cell_size; + let top = geometry.rect.top() + stair.cell.1 as f32 * geometry.cell_size; + let right = left + stair.width as f32 * geometry.cell_size; + let bottom = top + stair.height as f32 * geometry.cell_size; + + let tl = egui::pos2(left, top); + let tr = egui::pos2(right, top); + let br = egui::pos2(right, bottom); + let bl = egui::pos2(left, bottom); + + crate::rendering::draw_dashed_line(painter, tl, tr, stroke, 6.0, 6.0); + crate::rendering::draw_dashed_line(painter, tr, br, stroke, 6.0, 6.0); + crate::rendering::draw_dashed_line(painter, br, bl, stroke, 6.0, 6.0); + crate::rendering::draw_dashed_line(painter, bl, tl, stroke, 6.0, 6.0); + + if show_handles { + draw_resize_handle(painter, tl, ResizeCorner::TopLeft, color); + draw_resize_handle(painter, tr, ResizeCorner::TopRight, color); + draw_resize_handle(painter, br, ResizeCorner::BottomRight, color); + draw_resize_handle(painter, bl, ResizeCorner::BottomLeft, color); + } +} + +pub fn draw_corridor_hover_overlay( + app: &DungeonApp, + painter: &egui::Painter, + geometry: &GridGeometry, +) { + let Some(idx) = app.hover_corridor_idx else { + return; + }; + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &app.levels[app.settings.active_level_index]; + + let Some(corridor) = layout.corridors.get(idx) else { + return; + }; + + let color = Color32::from_gray(170); + let stroke = Stroke::new(2.0, color); + for pair in corridor.path.windows(2) { + let a = cell_center(geometry, pair[0].0, pair[0].1); + let b = cell_center(geometry, pair[1].0, pair[1].1); + crate::rendering::draw_dashed_line(painter, a, b, stroke, 6.0, 6.0); + } +} + +pub fn draw_door_hover_overlay(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) { + let Some(idx) = app.hover_door_idx else { + return; + }; + if app.settings.active_level_index >= app.levels.len() { + return; + } + let layout = &app.levels[app.settings.active_level_index]; + + let Some(door) = layout.doors.get(idx) else { + return; + }; + let color = Color32::from_gray(200); + let stroke = Stroke::new(2.0, color); + crate::rendering::draw_door_line( + painter, + geometry, + door.from, + door.to, + door_render_width(door), + stroke, + crate::rendering::DoorLineStyle::LongDash, + ); +} + +pub fn pointer_to_grid(pointer_pos: egui::Pos2, geometry: &GridGeometry) -> Option<(f32, f32)> { + if !geometry.rect.contains(pointer_pos) { + return None; + } + + let x = (pointer_pos.x - geometry.rect.left()) / geometry.cell_size; + let y = (pointer_pos.y - geometry.rect.top()) / geometry.cell_size; + Some((x, y)) +} + +pub fn reroute_path_through_cell( + original_path: &[(usize, usize)], + original_cell: (usize, usize), + target_cell: (usize, usize), + cols: usize, + rows: usize, + blocked_room_cells_set: &HashSet<(usize, usize)>, +) -> Option> { + let (&start, &end) = (original_path.first()?, original_path.last()?); + if start == end { + return None; + } + + let mut blocked = blocked_room_cells_set.clone(); + blocked.insert(original_cell); + blocked.remove(&start); + blocked.remove(&end); + blocked.remove(&target_cell); + + let first_leg = shortest_path_cells(start, target_cell, cols, rows, &blocked)?; + + let mut blocked_second = blocked.clone(); + for &cell in first_leg + .iter() + .skip(1) + .take(first_leg.len().saturating_sub(2)) + { + if cell != target_cell && cell != end { + blocked_second.insert(cell); + } + } + + let second_leg = shortest_path_cells(target_cell, end, cols, rows, &blocked_second) + .or_else(|| shortest_path_cells(target_cell, end, cols, rows, &blocked))?; + + let mut full_path = first_leg; + for cell in second_leg.into_iter().skip(1) { + if full_path.last().copied() != Some(cell) { + full_path.push(cell); + } + } + + simplify_path_loops(&mut full_path); + + if full_path.len() < 2 || !full_path.contains(&target_cell) { + return None; + } + + if original_cell != start && original_cell != end && full_path.contains(&original_cell) { + return None; + } + + Some(full_path) +} + +pub fn simplify_path_loops(path: &mut Vec<(usize, usize)>) { + let mut out = Vec::new(); + for &cell in path.iter() { + if let Some(pos) = out.iter().position(|&c| c == cell) { + out.truncate(pos + 1); + } else { + out.push(cell); + } + } + *path = out; +} + +pub fn manual_door_edge_allowed( + a_room: Option, + b_room: Option, + a_in_corridor: bool, + b_in_corridor: bool, +) -> bool { + if matches!((a_room, b_room), (Some(a), Some(b)) if a == b) { + return false; + } + + let a_is_feature = a_room.is_some() || a_in_corridor; + let b_is_feature = b_room.is_some() || b_in_corridor; + a_is_feature || b_is_feature +} + +pub fn resize_hit_cells( + room: &layout::Room, + room_idx: usize, + rooms: &[layout::Room], + cols: usize, + rows: usize, +) -> HashSet<(usize, usize)> { + let mut cells = HashSet::new(); + let corners = [ + (room.x as isize, room.y as isize, -1isize, -1isize), + ( + (room.x + room.width - 1) as isize, + room.y as isize, + 1isize, + -1isize, + ), + ( + room.x as isize, + (room.y + room.height - 1) as isize, + -1isize, + 1isize, + ), + ( + (room.x + room.width - 1) as isize, + (room.y + room.height - 1) as isize, + 1isize, + 1isize, + ), + ]; + + for (cx, cy, ox, oy) in corners { + let candidates = [ + (cx, cy), + (cx - ox, cy), + (cx, cy - oy), + (cx - ox, cy - oy), + (cx + ox, cy), + (cx, cy + oy), + (cx + ox, cy + oy), + ]; + + for (x, y) in candidates { + if x < 0 || y < 0 || x >= cols as isize || y >= rows as isize { + continue; + } + + let cell = (x as usize, y as usize); + let in_other_room = rooms.iter().enumerate().any(|(idx, other_room)| { + idx != room_idx + && cell.0 >= other_room.x + && cell.0 < other_room.x + other_room.width + && cell.1 >= other_room.y + && cell.1 < other_room.y + other_room.height + }); + if !in_other_room { + cells.insert(cell); + } + } + } + + cells +} + +pub fn draw_resize_handle( + painter: &egui::Painter, + corner: egui::Pos2, + which: ResizeCorner, + color: Color32, +) { + let size = 10.0; + let half = size * 0.5; + let rect = egui::Rect::from_center_size(corner, egui::vec2(size, size)); + painter.rect_filled(rect, 2.0, Color32::from_gray(30)); + painter.rect_stroke(rect, 2.0, Stroke::new(1.0, color), egui::StrokeKind::Middle); + + let center = corner; + let stroke = Stroke::new(1.0, color); + match which { + ResizeCorner::TopLeft => { + painter.line_segment( + [center, egui::pos2(center.x - half + 2.0, center.y)], + stroke, + ); + painter.line_segment( + [center, egui::pos2(center.x, center.y - half + 2.0)], + stroke, + ); + } + ResizeCorner::TopRight => { + painter.line_segment( + [center, egui::pos2(center.x + half - 2.0, center.y)], + stroke, + ); + painter.line_segment( + [center, egui::pos2(center.x, center.y - half + 2.0)], + stroke, + ); + } + ResizeCorner::BottomLeft => { + painter.line_segment( + [center, egui::pos2(center.x - half + 2.0, center.y)], + stroke, + ); + painter.line_segment( + [center, egui::pos2(center.x, center.y + half - 2.0)], + stroke, + ); + } + ResizeCorner::BottomRight => { + painter.line_segment( + [center, egui::pos2(center.x + half - 2.0, center.y)], + stroke, + ); + painter.line_segment( + [center, egui::pos2(center.x, center.y + half - 2.0)], + stroke, + ); + } + } +} + +pub fn door_edges_for( + door: &layout::Door, + cols: usize, + rows: usize, +) -> Vec<((usize, usize), (usize, usize))> { + let mut edges = Vec::new(); + if door.from.0.abs_diff(door.to.0) + door.from.1.abs_diff(door.to.1) != 1 { + return edges; + } + + let width = door_render_width(door).max(1) as isize; + let min_offset = -((width - 1) / 2); + let max_offset = width / 2; + + if door.from.0 != door.to.0 { + let y = door.from.1 as isize; + for dy in min_offset..=max_offset { + let ny = y + dy; + if ny < 0 || ny >= rows as isize { + continue; + } + let a = (door.from.0, ny as usize); + let b = (door.to.0, ny as usize); + edges.push(normalized_edge(a, b)); + } + } else { + let x = door.from.0 as isize; + for dx in min_offset..=max_offset { + let nx = x + dx; + if nx < 0 || nx >= cols as isize { + continue; + } + let a = (nx as usize, door.from.1); + let b = (nx as usize, door.to.1); + edges.push(normalized_edge(a, b)); + } + } + + edges +} + +pub fn door_render_width(door: &layout::Door) -> usize { + if door.span_width { + door.width.max(1) + } else { + 1 + } +} + +pub fn window_render_width(window: &layout::Window) -> usize { + if window.span_width { + window.width.max(1) + } else { + 1 + } +} + +pub fn normalized_edge(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) { + if a <= b { (a, b) } else { (b, a) } +} + +pub fn cell_center(geometry: &GridGeometry, col: usize, row: usize) -> egui::Pos2 { + egui::pos2( + geometry.rect.left() + (col as f32 + 0.5) * geometry.cell_size, + geometry.rect.top() + (row as f32 + 0.5) * geometry.cell_size, + ) +} + +pub fn cell_rect(geometry: &GridGeometry, col: usize, row: usize) -> egui::Rect { + let left = geometry.rect.left() + col as f32 * geometry.cell_size; + let top = geometry.rect.top() + row as f32 * geometry.cell_size; + egui::Rect::from_min_size( + egui::pos2(left, top), + egui::vec2(geometry.cell_size, geometry.cell_size), + ) +} + +pub fn marker_rect(marker: &layout::AreaMarker, geometry: &GridGeometry) -> egui::Rect { + let left = geometry.rect.left() + marker.cell.0 as f32 * geometry.cell_size; + let top = geometry.rect.top() + marker.cell.1 as f32 * geometry.cell_size; + egui::Rect::from_min_size( + egui::pos2(left, top), + egui::vec2( + marker.size as f32 * geometry.cell_size, + marker.size as f32 * geometry.cell_size, + ), + ) +} + +pub fn marker_contains_cell(marker: Option<&layout::AreaMarker>, cell: (usize, usize)) -> bool { + let Some(marker) = marker else { + return false; + }; + cell.0 >= marker.cell.0 + && cell.0 < marker.cell.0 + marker.size + && cell.1 >= marker.cell.1 + && cell.1 < marker.cell.1 + marker.size +} + +pub fn centered_marker_origin( + cell: (usize, usize), + size: usize, + cols: usize, + rows: usize, +) -> (usize, usize) { + let size = size.clamp(1, 10).min(cols.max(1)).min(rows.max(1)); + let half = size / 2; + let max_x = cols.saturating_sub(size); + let max_y = rows.saturating_sub(size); + ( + cell.0.saturating_sub(half).min(max_x), + cell.1.saturating_sub(half).min(max_y), + ) +} diff --git a/src/layout.rs b/src/layout/generation.rs similarity index 75% rename from src/layout.rs rename to src/layout/generation.rs index 1308e32..d434410 100644 --- a/src/layout.rs +++ b/src/layout/generation.rs @@ -1,177 +1,15 @@ -use serde::{Deserialize, Serialize}; -use std::collections::{HashSet, VecDeque}; - +use super::types::{ + AreaMarker, Corridor, Door, DoorSettings, DungeonLayout, Room, Staircase, Window, + WindowSettings, WindowSide, +}; +use super::utils::{ + SimpleRng, blocked_room_cells, corridor_cells, manhattan_distance, noisy_path, + normalized_cell_edge, overlaps_with_padding, room_index_at_cell, rooms_overlap, rooms_touch, + shared_boundary_edges, shared_opening_width, shortest_path_cells, shuffle_indices, +}; use crate::seed; use crate::ui::UiSettings; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Room { - pub x: usize, - pub y: usize, - pub width: usize, - pub height: usize, -} - -impl Room { - // Return the center cell of the room. - pub fn center_cell(&self) -> (usize, usize) { - (self.x + (self.width / 2), self.y + (self.height / 2)) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Corridor { - #[allow(dead_code)] - pub id: u64, - pub start_room_id: usize, - pub end_room_id: usize, - pub path: Vec<(usize, usize)>, - pub width: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Door { - pub from: (usize, usize), - pub to: (usize, usize), - pub width: usize, - pub span_width: bool, - pub locked: bool, - pub archway: bool, - pub secret: bool, - pub manual: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Window { - pub cell: (usize, usize), - pub side: WindowSide, - pub width: usize, - pub span_width: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TextLabel { - pub cell: (usize, usize), - pub text: String, - pub font_size: u16, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct AreaMarker { - pub cell: (usize, usize), - pub size: usize, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Staircase { - pub cell: (usize, usize), - pub width: usize, - pub height: usize, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum WindowSide { - Left, - Right, - Top, - Bottom, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub struct DoorSettings { - pub frequency_percent: usize, - pub room_hallway_percent: usize, - pub locked_percent: usize, - pub secret_percent: usize, - pub allow_middle_corridor_doors: bool, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -pub struct WindowSettings { - pub enabled: bool, - pub min_width: usize, - pub max_width: usize, - pub frequency_percent: usize, - pub room_hallway_percent: usize, - pub allow_internal_windows: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct DungeonLayout { - pub rooms: Vec, - pub corridors: Vec, - pub doors: Vec, - pub windows: Vec, - pub text_labels: Vec, - pub start_markers: Vec, - pub end_markers: Vec, - pub trap_markers: Vec, - pub monster_markers: Vec, - pub packed_rooms: bool, - pub stairs: Vec, -} - -impl Default for DungeonLayout { - fn default() -> Self { - Self { - rooms: Vec::new(), - corridors: Vec::new(), - doors: Vec::new(), - windows: Vec::new(), - text_labels: Vec::new(), - start_markers: Vec::new(), - end_markers: Vec::new(), - trap_markers: Vec::new(), - monster_markers: Vec::new(), - packed_rooms: false, - stairs: Vec::new(), - } - } -} - -impl DungeonLayout { - // Build an empty layout for the current packing mode. - pub(crate) fn empty(packed_rooms: bool) -> Self { - Self { - packed_rooms, - ..Self::default() - } - } - - // Build a layout from generated rooms and corridors. - fn from_generated_parts( - rooms: Vec, - corridors: Vec, - packed_rooms: bool, - ) -> Self { - Self { - rooms, - corridors, - packed_rooms, - ..Self::default() - } - } -} - -// Collect all room cells except those belonging to excluded room ids. -pub fn blocked_room_cells(rooms: &[Room], excluded_room_ids: &[usize]) -> HashSet<(usize, usize)> { - let excluded: HashSet = excluded_room_ids.iter().copied().collect(); - let mut blocked = HashSet::new(); - - for (room_idx, room) in rooms.iter().enumerate() { - if excluded.contains(&room_idx) { - continue; - } - - for x in room.x..(room.x + room.width) { - for y in room.y..(room.y + room.height) { - blocked.insert((x, y)); - } - } - } - - blocked -} +use std::collections::{HashSet, VecDeque}; // Build a layout based on settings and a derived deterministic seed. pub fn generate_layout( @@ -206,7 +44,7 @@ pub fn generate_layout( let mut corridor_rng = SimpleRng::new(seed::derive_seed(base_seed, 4)); let mut rooms = Vec::new(); - let mut corridors = Vec::new(); + let corridors = Vec::new(); if cols < 2 || rows < 2 || target_room_count == 0 { return DungeonLayout::empty(pack_rooms_without_corridors); @@ -308,6 +146,7 @@ pub fn generate_layout( 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]; @@ -930,163 +769,6 @@ fn shared_room_boundaries( boundaries } -fn shared_boundary_edges(a: &Room, b: &Room) -> Vec<((usize, usize), (usize, usize))> { - let mut edges = Vec::new(); - - if a.x + a.width == b.x || b.x + b.width == a.x { - let left = if a.x < b.x { a } else { b }; - let right = if a.x < b.x { b } else { a }; - let y0 = left.y.max(right.y); - let y1 = (left.y + left.height).min(right.y + right.height); - for y in y0..y1 { - edges.push(normalized_cell_edge( - (left.x + left.width - 1, y), - (right.x, y), - )); - } - } - - if a.y + a.height == b.y || b.y + b.height == a.y { - let top = if a.y < b.y { a } else { b }; - let bottom = if a.y < b.y { b } else { a }; - let x0 = top.x.max(bottom.x); - let x1 = (top.x + top.width).min(bottom.x + bottom.width); - for x in x0..x1 { - edges.push(normalized_cell_edge( - (x, top.y + top.height - 1), - (x, bottom.y), - )); - } - } - - edges -} - -pub fn room_index_at_cell(rooms: &[Room], cell: (usize, usize)) -> Option { - rooms.iter().position(|room| { - cell.0 >= room.x - && cell.0 < room.x + room.width - && cell.1 >= room.y - && cell.1 < room.y + room.height - }) -} - -// Compute corridor cells while excluding room cells. -pub fn corridor_cells(layout: &DungeonLayout, cols: usize, rows: usize) -> HashSet<(usize, usize)> { - let mut cells = HashSet::new(); - if cols == 0 || rows == 0 { - return cells; - } - - let mut room_cells = HashSet::new(); - for room in &layout.rooms { - for x in room.x..(room.x + room.width) { - for y in room.y..(room.y + room.height) { - room_cells.insert((x, y)); - } - } - } - - for corridor in &layout.corridors { - let width = corridor.width.max(1); - let min_offset = -((width as isize - 1) / 2); - let max_offset = width as isize / 2; - if corridor.path.len() == 1 { - let (x, y) = corridor.path[0]; - for dy in min_offset..=max_offset { - let ny = y as isize + dy; - if ny < 0 || ny >= rows as isize { - continue; - } - let cell = (x, ny as usize); - if !room_cells.contains(&cell) { - cells.insert(cell); - } - } - continue; - } - - for pair in corridor.path.windows(2) { - let a = pair[0]; - let b = pair[1]; - if a == b { - continue; - } - - if a.0 != b.0 { - let x0 = a.0.min(b.0); - let x1 = a.0.max(b.0); - let y = a.1 as isize; - for x in x0..=x1 { - for dy in min_offset..=max_offset { - let ny = y + dy; - if ny < 0 || ny >= rows as isize { - continue; - } - let cell = (x, ny as usize); - if !room_cells.contains(&cell) { - cells.insert(cell); - } - } - } - } else { - let y0 = a.1.min(b.1); - let y1 = a.1.max(b.1); - let x = a.0 as isize; - for y in y0..=y1 { - for dx in min_offset..=max_offset { - let nx = x + dx; - if nx < 0 || nx >= cols as isize { - continue; - } - let cell = (nx as usize, y); - if !room_cells.contains(&cell) { - cells.insert(cell); - } - } - } - } - } - - for turn in corridor.path.windows(3) { - let prev = turn[0]; - let corner = turn[1]; - let next = turn[2]; - let incoming = ( - corner.0 as isize - prev.0 as isize, - corner.1 as isize - prev.1 as isize, - ); - let outgoing = ( - next.0 as isize - corner.0 as isize, - next.1 as isize - corner.1 as isize, - ); - if incoming == outgoing { - continue; - } - - for dx in min_offset..=max_offset { - let nx = corner.0 as isize + dx; - if nx < 0 || nx >= cols as isize { - continue; - } - for dy in min_offset..=max_offset { - let ny = corner.1 as isize + dy; - if ny < 0 || ny >= rows as isize { - continue; - } - - let cell = (nx as usize, ny as usize); - if !room_cells.contains(&cell) { - cells.insert(cell); - } - } - } - } - } - - cells -} - const START_COUNT_STREAM: u64 = 10_001; const END_COUNT_STREAM: u64 = 10_002; const START_ROOM_STREAM_BASE: u64 = 11_000; @@ -1982,41 +1664,6 @@ fn shuffle_rooms(rooms: &mut [Room], rng: &mut SimpleRng) { } } -fn rects_overlap( - ax: usize, - ay: usize, - aw: usize, - ah: usize, - bx: usize, - by: usize, - bw: usize, - bh: usize, -) -> bool { - let a_right = ax + aw; - let a_bottom = ay + ah; - let b_right = bx + bw; - let b_bottom = by + bh; - - ax < b_right && a_right > bx && ay < b_bottom && a_bottom > by -} - -fn rooms_overlap(a: &Room, b: &Room) -> bool { - rects_overlap(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height) -} - -fn rooms_touch(a: &Room, b: &Room) -> bool { - !shared_boundary_edges(a, b).is_empty() -} - -fn shared_opening_width(a: &Room, b: &Room, span: usize, default_width: usize) -> usize { - let max_width = if a.x + a.width == b.x || b.x + b.width == a.x { - a.height.min(b.height) - } else { - a.width.min(b.width) - }; - default_width.max(1).min(span).min(max_width.max(1)) -} - // Create a connected graph of room-to-room edges with optional dead ends. fn build_room_connection_edges( centers: &[(usize, usize)], @@ -2127,253 +1774,3 @@ fn push_unique_room_edge( edges.push((a, b)); } } - -// Shuffle indices in place using the provided RNG. -fn shuffle_indices(indices: &mut [usize], rng: &mut SimpleRng) { - if indices.len() <= 1 { - return; - } - for i in (1..indices.len()).rev() { - let j = rng.range_inclusive(0, i); - indices.swap(i, j); - } -} - -// Compute the shortest grid path between two cells using BFS. -pub fn shortest_path_cells( - start: (usize, usize), - end: (usize, usize), - cols: usize, - rows: usize, - blocked: &HashSet<(usize, usize)>, -) -> Option> { - if start == end { - return Some(vec![start]); - } - if blocked.contains(&start) || blocked.contains(&end) { - return None; - } - - let total = cols.saturating_mul(rows); - if total == 0 { - return None; - } - - let index = |p: (usize, usize)| -> usize { p.1 * cols + p.0 }; - let coord = |idx: usize| -> (usize, usize) { (idx % cols, idx / cols) }; - - let start_idx = index(start); - let end_idx = index(end); - - let mut queue = VecDeque::new(); - let mut visited = vec![false; total]; - let mut parent: Vec> = vec![None; total]; - - visited[start_idx] = true; - queue.push_back(start_idx); - - while let Some(current) = queue.pop_front() { - if current == end_idx { - break; - } - - let (x, y) = coord(current); - let neighbors = [ - x.checked_sub(1).map(|nx| (nx, y)), - (x + 1 < cols).then_some((x + 1, y)), - y.checked_sub(1).map(|ny| (x, ny)), - (y + 1 < rows).then_some((x, y + 1)), - ]; - - for neighbor in neighbors.into_iter().flatten() { - if blocked.contains(&neighbor) { - continue; - } - let n_idx = index(neighbor); - if !visited[n_idx] { - visited[n_idx] = true; - parent[n_idx] = Some(current); - queue.push_back(n_idx); - } - } - } - - if !visited[end_idx] { - return None; - } - - let mut path = Vec::new(); - let mut current = end_idx; - path.push(coord(current)); - - while let Some(prev) = parent[current] { - current = prev; - path.push(coord(current)); - } - - path.reverse(); - Some(path) -} - -// Generate a noisy path biased toward the target cell. -fn noisy_path( - start: (usize, usize), - end: (usize, usize), - cols: usize, - rows: usize, - randomness: f32, - blocked: &HashSet<(usize, usize)>, - rng: &mut SimpleRng, -) -> Vec<(usize, usize)> { - if start == end { - return vec![start]; - } - - let mut path = vec![start]; - let mut visited = HashSet::new(); - visited.insert(start); - - let mut current = start; - let mut prev_dir = (0isize, 0isize); - let max_steps = cols.saturating_mul(rows).max(32); - - for _ in 0..max_steps { - if current == end { - break; - } - - let mut neighbors = Vec::with_capacity(4); - let (x, y) = current; - if x > 0 { - neighbors.push((x - 1, y)); - } - if x + 1 < cols { - neighbors.push((x + 1, y)); - } - if y > 0 { - neighbors.push((x, y - 1)); - } - if y + 1 < rows { - neighbors.push((x, y + 1)); - } - - if neighbors.is_empty() { - break; - } - - let mut best = neighbors[0]; - let mut best_score = f32::INFINITY; - - for &candidate in &neighbors { - if blocked.contains(&candidate) && candidate != end { - continue; - } - - let step_dir = ( - candidate.0 as isize - current.0 as isize, - candidate.1 as isize - current.1 as isize, - ); - - let dist = manhattan_distance(candidate, end) as f32; - let progress_weight = 1.0 - (0.85 * randomness); - let revisit_penalty = if visited.contains(&candidate) { - 2.5 + (2.0 * randomness) - } else { - 0.0 - }; - let turn_penalty = if prev_dir == (0, 0) || prev_dir == step_dir { - 0.0 - } else { - 0.6 - (0.35 * randomness) - }; - let noise = rng.next_f32() * 8.0 * randomness; - let score = (dist * progress_weight) + revisit_penalty + turn_penalty + noise; - - if score < best_score { - best_score = score; - best = candidate; - } - } - - prev_dir = ( - best.0 as isize - current.0 as isize, - best.1 as isize - current.1 as isize, - ); - current = best; - path.push(current); - visited.insert(current); - } - - if current != end - && let Some(tail) = shortest_path_cells(current, end, cols, rows, blocked) - { - for &cell in tail.iter().skip(1) { - path.push(cell); - } - } - - path -} - -// Test whether two rooms overlap with extra padding. -fn overlaps_with_padding(a: &Room, b: &Room, padding: usize) -> bool { - let a_left = a.x.saturating_sub(padding); - let a_top = a.y.saturating_sub(padding); - let a_right = a.x + a.width + padding; - let a_bottom = a.y + a.height + padding; - - let b_left = b.x; - let b_top = b.y; - let b_right = b.x + b.width; - let b_bottom = b.y + b.height; - - a_left < b_right && a_right > b_left && a_top < b_bottom && a_bottom > b_top -} - -// Compute Manhattan distance between two grid cells. -fn manhattan_distance(a: (usize, usize), b: (usize, usize)) -> usize { - a.0.abs_diff(b.0) + a.1.abs_diff(b.1) -} - -// Normalize a cell edge ordering. -fn normalized_cell_edge(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) { - if a <= b { (a, b) } else { (b, a) } -} - -struct SimpleRng { - state: u64, -} - -impl SimpleRng { - // Create a small deterministic RNG with a fallback seed. - fn new(seed: u64) -> Self { - let state = if seed == 0 { - 0xA5A5_A5A5_1234_5678 - } else { - seed - }; - Self { state } - } - - // Return the next random u32. - fn next_u32(&mut self) -> u32 { - self.state ^= self.state >> 12; - self.state ^= self.state << 25; - self.state ^= self.state >> 27; - (self.state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 32) as u32 - } - - // Return the next random f32 in [0,1]. - fn next_f32(&mut self) -> f32 { - self.next_u32() as f32 / u32::MAX as f32 - } - - // Generate a random usize between min and max inclusive. - fn range_inclusive(&mut self, min: usize, max: usize) -> usize { - if min >= max { - return min; - } - let width = max - min + 1; - min + (self.next_u32() as usize % width) - } -} diff --git a/src/layout/mod.rs b/src/layout/mod.rs new file mode 100644 index 0000000..704a3ed --- /dev/null +++ b/src/layout/mod.rs @@ -0,0 +1,7 @@ +pub mod generation; +pub mod types; +pub mod utils; + +pub use generation::*; +pub use types::*; +pub use utils::*; diff --git a/src/layout/types.rs b/src/layout/types.rs new file mode 100644 index 0000000..266ec85 --- /dev/null +++ b/src/layout/types.rs @@ -0,0 +1,150 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Room { + pub x: usize, + pub y: usize, + pub width: usize, + pub height: usize, +} + +impl Room { + // Return the center cell of the room. + pub fn center_cell(&self) -> (usize, usize) { + (self.x + (self.width / 2), self.y + (self.height / 2)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Corridor { + #[allow(dead_code)] + pub id: u64, + pub start_room_id: usize, + pub end_room_id: usize, + pub path: Vec<(usize, usize)>, + pub width: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Door { + pub from: (usize, usize), + pub to: (usize, usize), + pub width: usize, + pub span_width: bool, + pub locked: bool, + pub archway: bool, + pub secret: bool, + pub manual: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Window { + pub cell: (usize, usize), + pub side: WindowSide, + pub width: usize, + pub span_width: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TextLabel { + pub cell: (usize, usize), + pub text: String, + pub font_size: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AreaMarker { + pub cell: (usize, usize), + pub size: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Staircase { + pub cell: (usize, usize), + pub width: usize, + pub height: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum WindowSide { + Left, + Right, + Top, + Bottom, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct DoorSettings { + pub frequency_percent: usize, + pub room_hallway_percent: usize, + pub locked_percent: usize, + pub secret_percent: usize, + pub allow_middle_corridor_doors: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct WindowSettings { + pub enabled: bool, + pub min_width: usize, + pub max_width: usize, + pub frequency_percent: usize, + pub room_hallway_percent: usize, + pub allow_internal_windows: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DungeonLayout { + pub rooms: Vec, + pub corridors: Vec, + pub doors: Vec, + pub windows: Vec, + pub text_labels: Vec, + pub start_markers: Vec, + pub end_markers: Vec, + pub trap_markers: Vec, + pub monster_markers: Vec, + pub packed_rooms: bool, + pub stairs: Vec, +} + +impl Default for DungeonLayout { + fn default() -> Self { + Self { + rooms: Vec::new(), + corridors: Vec::new(), + doors: Vec::new(), + windows: Vec::new(), + text_labels: Vec::new(), + start_markers: Vec::new(), + end_markers: Vec::new(), + trap_markers: Vec::new(), + monster_markers: Vec::new(), + packed_rooms: false, + stairs: Vec::new(), + } + } +} + +impl DungeonLayout { + // Build an empty layout for the current packing mode. + pub(crate) fn empty(packed_rooms: bool) -> Self { + Self { + packed_rooms, + ..Self::default() + } + } + + // Build a layout from generated rooms and corridors. + pub(crate) fn from_generated_parts( + rooms: Vec, + corridors: Vec, + packed_rooms: bool, + ) -> Self { + Self { + rooms, + corridors, + packed_rooms, + ..Self::default() + } + } +} diff --git a/src/layout/utils.rs b/src/layout/utils.rs new file mode 100644 index 0000000..fc8fc74 --- /dev/null +++ b/src/layout/utils.rs @@ -0,0 +1,467 @@ +use super::types::{DungeonLayout, Room}; +use std::collections::{HashSet, VecDeque}; + +pub struct SimpleRng { + pub state: u64, +} + +impl SimpleRng { + // Create a small deterministic RNG with a fallback seed. + pub fn new(seed: u64) -> Self { + let state = if seed == 0 { + 0xA5A5_A5A5_1234_5678 + } else { + seed + }; + Self { state } + } + + // Return the next random u32. + pub fn next_u32(&mut self) -> u32 { + self.state ^= self.state >> 12; + self.state ^= self.state << 25; + self.state ^= self.state >> 27; + (self.state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 32) as u32 + } + + // Return the next random f32 in [0,1]. + pub fn next_f32(&mut self) -> f32 { + self.next_u32() as f32 / u32::MAX as f32 + } + + // Generate a random usize between min and max inclusive. + pub fn range_inclusive(&mut self, min: usize, max: usize) -> usize { + if min >= max { + return min; + } + let width = max - min + 1; + min + (self.next_u32() as usize % width) + } +} + +// Collect all room cells except those belonging to excluded room ids. +pub fn blocked_room_cells(rooms: &[Room], excluded_room_ids: &[usize]) -> HashSet<(usize, usize)> { + let excluded: HashSet = excluded_room_ids.iter().copied().collect(); + let mut blocked = HashSet::new(); + + for (room_idx, room) in rooms.iter().enumerate() { + if excluded.contains(&room_idx) { + continue; + } + + for x in room.x..(room.x + room.width) { + for y in room.y..(room.y + room.height) { + blocked.insert((x, y)); + } + } + } + + blocked +} + +// Compute corridor cells while excluding room cells. +pub fn corridor_cells(layout: &DungeonLayout, cols: usize, rows: usize) -> HashSet<(usize, usize)> { + let mut cells = HashSet::new(); + if cols == 0 || rows == 0 { + return cells; + } + + let mut room_cells = HashSet::new(); + for room in &layout.rooms { + for x in room.x..(room.x + room.width) { + for y in room.y..(room.y + room.height) { + room_cells.insert((x, y)); + } + } + } + + for corridor in &layout.corridors { + let width = corridor.width.max(1); + let min_offset = -((width as isize - 1) / 2); + let max_offset = width as isize / 2; + if corridor.path.len() == 1 { + let (x, y) = corridor.path[0]; + for dy in min_offset..=max_offset { + let ny = y as isize + dy; + if ny < 0 || ny >= rows as isize { + continue; + } + let cell = (x, ny as usize); + if !room_cells.contains(&cell) { + cells.insert(cell); + } + } + continue; + } + + for pair in corridor.path.windows(2) { + let a = pair[0]; + let b = pair[1]; + if a == b { + continue; + } + + if a.0 != b.0 { + let x0 = a.0.min(b.0); + let x1 = a.0.max(b.0); + let y = a.1 as isize; + for x in x0..=x1 { + for dy in min_offset..=max_offset { + let ny = y + dy; + if ny < 0 || ny >= rows as isize { + continue; + } + let cell = (x, ny as usize); + if !room_cells.contains(&cell) { + cells.insert(cell); + } + } + } + } else { + let y0 = a.1.min(b.1); + let y1 = a.1.max(b.1); + let x = a.0 as isize; + for y in y0..=y1 { + for dx in min_offset..=max_offset { + let nx = x + dx; + if nx < 0 || nx >= cols as isize { + continue; + } + let cell = (nx as usize, y); + if !room_cells.contains(&cell) { + cells.insert(cell); + } + } + } + } + } + + for turn in corridor.path.windows(3) { + let prev = turn[0]; + let corner = turn[1]; + let next = turn[2]; + let incoming = ( + corner.0 as isize - prev.0 as isize, + corner.1 as isize - prev.1 as isize, + ); + let outgoing = ( + next.0 as isize - corner.0 as isize, + next.1 as isize - corner.1 as isize, + ); + if incoming == outgoing { + continue; + } + + for dx in min_offset..=max_offset { + let nx = corner.0 as isize + dx; + if nx < 0 || nx >= cols as isize { + continue; + } + for dy in min_offset..=max_offset { + let ny = corner.1 as isize + dy; + if ny < 0 || ny >= rows as isize { + continue; + } + + let cell = (nx as usize, ny as usize); + if !room_cells.contains(&cell) { + cells.insert(cell); + } + } + } + } + } + + cells +} + +pub fn room_index_at_cell(rooms: &[Room], cell: (usize, usize)) -> Option { + rooms.iter().position(|room| { + cell.0 >= room.x + && cell.0 < room.x + room.width + && cell.1 >= room.y + && cell.1 < room.y + room.height + }) +} + +// Normalize a cell edge ordering. +pub fn normalized_cell_edge( + a: (usize, usize), + b: (usize, usize), +) -> ((usize, usize), (usize, usize)) { + if a <= b { (a, b) } else { (b, a) } +} + +// Compute Manhattan distance between two grid cells. +pub fn manhattan_distance(a: (usize, usize), b: (usize, usize)) -> usize { + a.0.abs_diff(b.0) + a.1.abs_diff(b.1) +} + +// Test whether two rooms overlap with extra padding. +pub fn overlaps_with_padding(a: &Room, b: &Room, padding: usize) -> bool { + let a_left = a.x.saturating_sub(padding); + let a_top = a.y.saturating_sub(padding); + let a_right = a.x + a.width + padding; + let a_bottom = a.y + a.height + padding; + + let b_left = b.x; + let b_top = b.y; + let b_right = b.x + b.width; + let b_bottom = b.y + b.height; + + a_left < b_right && a_right > b_left && a_top < b_bottom && a_bottom > b_top +} + +pub fn rects_overlap( + ax: usize, + ay: usize, + aw: usize, + ah: usize, + bx: usize, + by: usize, + bw: usize, + bh: usize, +) -> bool { + let a_right = ax + aw; + let a_bottom = ay + ah; + let b_right = bx + bw; + let b_bottom = by + bh; + + ax < b_right && a_right > bx && ay < b_bottom && a_bottom > by +} + +pub fn rooms_overlap(a: &Room, b: &Room) -> bool { + rects_overlap(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height) +} + +pub fn rooms_touch(a: &Room, b: &Room) -> bool { + !shared_boundary_edges(a, b).is_empty() +} + +pub fn shared_boundary_edges(a: &Room, b: &Room) -> Vec<((usize, usize), (usize, usize))> { + let mut edges = Vec::new(); + + if a.x + a.width == b.x || b.x + b.width == a.x { + let left = if a.x < b.x { a } else { b }; + let right = if a.x < b.x { b } else { a }; + let y0 = left.y.max(right.y); + let y1 = (left.y + left.height).min(right.y + right.height); + for y in y0..y1 { + edges.push(normalized_cell_edge( + (left.x + left.width - 1, y), + (right.x, y), + )); + } + } + + if a.y + a.height == b.y || b.y + b.height == a.y { + let top = if a.y < b.y { a } else { b }; + let bottom = if a.y < b.y { b } else { a }; + let x0 = top.x.max(bottom.x); + let x1 = (top.x + top.width).min(bottom.x + bottom.width); + for x in x0..x1 { + edges.push(normalized_cell_edge( + (x, top.y + top.height - 1), + (x, bottom.y), + )); + } + } + + edges +} + +pub fn shared_opening_width(a: &Room, b: &Room, span: usize, default_width: usize) -> usize { + let max_width = if a.x + a.width == b.x || b.x + b.width == a.x { + a.height.min(b.height) + } else { + a.width.min(b.width) + }; + default_width.max(1).min(span).min(max_width.max(1)) +} + +// Compute the shortest grid path between two cells using BFS. +pub fn shortest_path_cells( + start: (usize, usize), + end: (usize, usize), + cols: usize, + rows: usize, + blocked: &HashSet<(usize, usize)>, +) -> Option> { + if start == end { + return Some(vec![start]); + } + if blocked.contains(&start) || blocked.contains(&end) { + return None; + } + + let total = cols.saturating_mul(rows); + if total == 0 { + return None; + } + + let index = |p: (usize, usize)| -> usize { p.1 * cols + p.0 }; + let coord = |idx: usize| -> (usize, usize) { (idx % cols, idx / cols) }; + + let start_idx = index(start); + let end_idx = index(end); + + let mut queue = VecDeque::new(); + let mut visited = vec![false; total]; + let mut parent: Vec> = vec![None; total]; + + visited[start_idx] = true; + queue.push_back(start_idx); + + while let Some(current) = queue.pop_front() { + if current == end_idx { + break; + } + + let (x, y) = coord(current); + let neighbors = [ + x.checked_sub(1).map(|nx| (nx, y)), + (x + 1 < cols).then_some((x + 1, y)), + y.checked_sub(1).map(|ny| (x, ny)), + (y + 1 < rows).then_some((x, y + 1)), + ]; + + for neighbor in neighbors.into_iter().flatten() { + if blocked.contains(&neighbor) { + continue; + } + let n_idx = index(neighbor); + if !visited[n_idx] { + visited[n_idx] = true; + parent[n_idx] = Some(current); + queue.push_back(n_idx); + } + } + } + + if !visited[end_idx] { + return None; + } + + let mut path = Vec::new(); + let mut current = end_idx; + path.push(coord(current)); + + while let Some(prev) = parent[current] { + current = prev; + path.push(coord(current)); + } + + path.reverse(); + Some(path) +} + +// Generate a noisy path biased toward the target cell. +pub fn noisy_path( + start: (usize, usize), + end: (usize, usize), + cols: usize, + rows: usize, + randomness: f32, + blocked: &HashSet<(usize, usize)>, + rng: &mut SimpleRng, +) -> Vec<(usize, usize)> { + if start == end { + return vec![start]; + } + + let mut path = vec![start]; + let mut visited = HashSet::new(); + visited.insert(start); + + let mut current = start; + let mut prev_dir = (0isize, 0isize); + let max_steps = cols.saturating_mul(rows).max(32); + + for _ in 0..max_steps { + if current == end { + break; + } + + let mut neighbors = Vec::with_capacity(4); + let (x, y) = current; + if x > 0 { + neighbors.push((x - 1, y)); + } + if x + 1 < cols { + neighbors.push((x + 1, y)); + } + if y > 0 { + neighbors.push((x, y - 1)); + } + if y + 1 < rows { + neighbors.push((x, y + 1)); + } + + if neighbors.is_empty() { + break; + } + + let mut best = neighbors[0]; + let mut best_score = f32::INFINITY; + + for &candidate in &neighbors { + if blocked.contains(&candidate) && candidate != end { + continue; + } + + let step_dir = ( + candidate.0 as isize - current.0 as isize, + candidate.1 as isize - current.1 as isize, + ); + + let dist = manhattan_distance(candidate, end) as f32; + let progress_weight = 1.0 - (0.85 * randomness); + let revisit_penalty = if visited.contains(&candidate) { + 2.5 + (2.0 * randomness) + } else { + 0.0 + }; + let turn_penalty = if prev_dir == (0, 0) || prev_dir == step_dir { + 0.0 + } else { + 0.6 - (0.35 * randomness) + }; + let noise = rng.next_f32() * 8.0 * randomness; + let score = (dist * progress_weight) + revisit_penalty + turn_penalty + noise; + + if score < best_score { + best_score = score; + best = candidate; + } + } + + prev_dir = ( + best.0 as isize - current.0 as isize, + best.1 as isize - current.1 as isize, + ); + current = best; + path.push(current); + visited.insert(current); + } + + if current != end + && let Some(tail) = shortest_path_cells(current, end, cols, rows, blocked) + { + for &cell in tail.iter().skip(1) { + path.push(cell); + } + } + + path +} + +// Shuffle indices in place using the provided RNG. +pub fn shuffle_indices(indices: &mut [usize], rng: &mut SimpleRng) { + if indices.len() <= 1 { + return; + } + for i in (1..indices.len()).rev() { + let j = rng.range_inclusive(0, i); + indices.swap(i, j); + } +} diff --git a/src/main.rs b/src/main.rs index 9807a5d..0e7a68a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,36 +1,14 @@ +mod app; mod exporter; +mod interact; mod layout; +mod rendering; mod saveandload; mod seed; mod settings; mod ui; -use std::collections::HashSet; -use std::sync::mpsc; -use std::thread; - -use eframe::egui; -use egui::{Color32, Stroke}; -use layout::{ - DoorSettings, DungeonLayout, WindowSettings, blocked_room_cells, corridor_cells, - populate_random_markers, populate_stairs, shortest_path_cells, -}; -use ui::{AddTool, UiSettings, draw_legend_panel, draw_level_tabs, draw_side_panel}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ManualDoorKind { - Archway, - Regular, - Locked, - Secret, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct AppSnapshot { - settings: UiSettings, - levels: Vec, - suppressed_auto_door_edges: HashSet<((usize, usize), (usize, usize))>, -} +use app::DungeonApp; // Boot the native app and start the egui event loop. fn main() -> eframe::Result<()> { @@ -42,3798 +20,3 @@ fn main() -> eframe::Result<()> { Box::new(|_cc| Ok(Box::new(DungeonApp::default()))), ) } - -struct DungeonApp { - settings: UiSettings, - levels: Vec, - suppressed_auto_door_edges: HashSet<((usize, usize), (usize, usize))>, - undo_stack: Vec, - redo_stack: Vec, - drag_state: Option, - export_rx: Option>, - export_progress: Option, - pending_delete: bool, - add_corridor_drag: Option, - resize_state: Option, - hover_room_idx: Option, - hover_corridor_idx: Option, - hover_door_idx: Option, - hover_text_idx: Option, - hover_marker: Option, - hover_level_idx: Option, - hover_stair_idx: Option, -} - -impl Default for DungeonApp { - // Build the app with persisted settings and a generated layout. - fn default() -> Self { - let settings = settings::load_settings().unwrap_or_default(); - let mut app = Self { - settings, - levels: Vec::new(), - suppressed_auto_door_edges: HashSet::new(), - undo_stack: Vec::new(), - redo_stack: Vec::new(), - drag_state: None, - export_rx: None, - export_progress: None, - pending_delete: false, - add_corridor_drag: None, - resize_state: None, - hover_room_idx: None, - hover_corridor_idx: None, - hover_door_idx: None, - hover_text_idx: None, - hover_marker: None, - hover_level_idx: None, - hover_stair_idx: None, - }; - if app.settings.composition_mode { - app.enter_composition_layout(); - } else { - app.levels = generate_all_levels(&app.settings); - app.refresh_stairs(); - } - app - } -} - -impl Drop for DungeonApp { - // Persist settings when the app is being dropped. - fn drop(&mut self) { - if let Err(err) = settings::save_settings(&self.settings) { - eprintln!("Failed to save settings: {err}"); - } - } -} - -impl eframe::App for DungeonApp { - // Render UI, handle input, and update app state each frame. - fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { - let panel_result = draw_side_panel( - ctx, - &mut self.settings, - self.export_progress - .map(|progress| (progress.completed, progress.total)), - self.levels.len(), - ); - let undo_requested = ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::Z)); - let redo_requested = ctx.input(|i| { - (i.modifiers.command && i.key_pressed(egui::Key::Y)) - || (i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::Z)) - }); - let escape_pressed = ctx.input(|i| i.key_pressed(egui::Key::Escape)); - - if escape_pressed || panel_result.settings_changed { - self.settings.add_tool = AddTool::None; - } - - clamp_dependent_settings(&mut self.settings); - - if undo_requested { - self.undo(); - } else if redo_requested { - self.redo(); - } - - if panel_result.clear_clicked { - self.push_undo_snapshot(); - self.clear_layout(); - } else if panel_result.reset_clicked { - self.push_undo_snapshot(); - if self.settings.composition_mode { - self.enter_composition_layout(); - } else { - self.regenerate_layout(); - } - } else if panel_result.settings_changed && !self.settings.composition_mode { - self.push_undo_snapshot(); - self.regenerate_layout(); - } else if panel_result.save_clicked { - let mut layouts = Vec::new(); - let mut svgs = Vec::new(); - for layout in &self.levels { - layouts.push(layout.clone()); - svgs.push(exporter::build_svg(layout, &self.settings)); - } - if let Err(err) = saveandload::save_dungeon(&self.settings, layouts, svgs) { - eprintln!("{err}"); - } - } else if panel_result.load_clicked { - match saveandload::load_dungeon() { - Ok(data) => { - self.push_undo_snapshot(); - self.settings = data.settings; - self.levels = data.layouts; - self.settings.active_level_index = 0; - // Don't regenerate_layout() here to preserve the exact loaded state. - } - Err(err) => { - eprintln!("{err}"); - } - } - } else if panel_result.new_level_clicked { - self.push_undo_snapshot(); - if self.settings.composition_mode { - self.levels.push(DungeonLayout::empty( - self.settings.pack_rooms_without_corridors, - )); - } else { - self.levels - .push(generate_level(&self.settings, self.levels.len())); - self.refresh_stairs(); - } - self.settings.active_level_index = self.levels.len() - 1; - } - if ctx.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) { - self.pending_delete = true; - } - if panel_result.export_clicked && self.export_rx.is_none() { - let target = match exporter::select_export_target(&self.settings) { - Ok(target) => target, - Err(err) => { - eprintln!("{err}"); - return; - } - }; - - // Update the last export path in settings to remember the directory. - match &target { - exporter::ExportTarget::File(path) => { - if let Some(parent) = path.parent() { - self.settings.last_export_path = Some(parent.to_string_lossy().to_string()); - } - } - exporter::ExportTarget::Folder(path) => { - self.settings.last_export_path = Some(path.to_string_lossy().to_string()); - } - } - - let layouts = self.levels.clone(); - let settings = self.settings.clone(); - let (tx, rx) = mpsc::channel(); - thread::spawn(move || { - let result = exporter::export_to_target(&layouts, &settings, target, &tx); - let _ = tx.send(exporter::ExportEvent::Finished(result)); - }); - self.export_progress = Some(exporter::ExportProgress { - completed: 0, - total: 1, - }); - self.export_rx = Some(rx); - } - - let mut export_finished = false; - if let Some(rx) = &self.export_rx { - loop { - match rx.try_recv() { - Ok(exporter::ExportEvent::Progress(progress)) => { - self.export_progress = Some(progress); - ctx.request_repaint(); - } - Ok(exporter::ExportEvent::Finished(Ok(_))) => { - export_finished = true; - break; - } - Ok(exporter::ExportEvent::Finished(Err(err))) => { - eprintln!("{err}"); - export_finished = true; - break; - } - Err(mpsc::TryRecvError::Empty) => break, - Err(mpsc::TryRecvError::Disconnected) => { - export_finished = true; - break; - } - } - } - } - if export_finished { - self.export_rx = None; - self.export_progress = None; - } - - if draw_legend_panel(ctx, &mut self.settings) { - self.push_undo_snapshot(); - if self.settings.composition_mode { - self.enter_composition_layout(); - } else { - self.regenerate_layout(); - } - } - - egui::CentralPanel::default().show(ctx, |ui| { - // Clear level hover state so it only reflects current frame. - self.hover_level_idx = - draw_level_tabs(ui, self.levels.len(), &mut self.settings.active_level_index); - ui.separator(); - - let available = ui.available_size_before_wrap(); - let (response, painter) = ui.allocate_painter(available, egui::Sense::click_and_drag()); - let canvas = response.rect.shrink(12.0); - let geometry = draw_grid(&painter, canvas, self.settings.cols, self.settings.rows); - self.update_hover_targets(ctx, &geometry); - - // Check for level deletion keybind when hovering over a level tab. - let level_delete_requested = ctx.input(|i| { - (i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) - && self.hover_level_idx.is_some() - }); - if level_delete_requested { - if let Some(level_idx) = self.hover_level_idx { - self.delete_level(level_idx); - } - } - - let resizing = self.handle_resize(ctx, &response, &geometry); - if !resizing { - self.handle_drag(ctx, &response, &geometry); - } - self.handle_add_tool(&response, &geometry); - if self.pending_delete { - if let Some(pointer_pos) = ctx.pointer_interact_pos() { - self.delete_at_pointer(pointer_pos, &geometry); - } - self.pending_delete = false; - } - - if self.settings.active_level_index < self.levels.len() { - draw_layout( - &painter, - &geometry, - &self.levels[self.settings.active_level_index], - self.settings.colorblind_mode, - self.hover_text_idx, - self.hover_marker, - self.hover_stair_idx, - ); - } - self.draw_add_overlay(&painter, &geometry); - self.draw_add_tool_ghost(&painter, &geometry); - self.draw_resize_overlay(&painter, &geometry); - self.draw_corridor_hover_overlay(&painter, &geometry); - self.draw_door_hover_overlay(&painter, &geometry); - }); - } -} - -fn generate_all_levels(settings: &UiSettings) -> Vec { - let level_count = if settings.min_levels == settings.max_levels { - settings.min_levels - } else { - let range_seed = seed::derive_seed(settings.seed, 0x1E_7E_1_u64); - (range_seed as usize % (settings.max_levels - settings.min_levels + 1)) - + settings.min_levels - }; - - let mut levels = Vec::with_capacity(level_count); - for i in 0..level_count { - levels.push(generate_level(settings, i)); - } - levels -} - -// Generate one dungeon level from the current settings. -fn generate_level(settings: &UiSettings, level_index: usize) -> DungeonLayout { - let level_seed = seed::derive_seed(settings.seed, level_index as u64); - let layout = layout::generate_layout( - settings.cols, - settings.rows, - settings.room_count, - level_seed, - settings.min_room_size, - settings.max_room_size, - settings.square_rooms_only, - settings.min_corridor_width, - settings.max_corridor_width, - settings.corridor_randomness, - settings.dead_end_rooms_percent, - settings.pack_rooms_without_corridors, - door_settings_from_ui(settings), - window_settings_from_ui(settings), - ); - - populate_random_markers(layout, settings) -} - -fn clamp_dependent_settings(settings: &mut UiSettings) { - if settings.min_room_size > settings.max_room_size { - settings.max_room_size = settings.min_room_size; - } - if settings.min_corridor_width == 0 { - settings.min_corridor_width = 1; - } - if settings.min_corridor_width > settings.max_corridor_width { - settings.max_corridor_width = settings.min_corridor_width; - } - if settings.min_window_width > settings.max_window_width { - settings.max_window_width = settings.min_window_width; - } - if settings.min_start_marker_size > settings.max_start_marker_size { - settings.max_start_marker_size = settings.min_start_marker_size; - } - if settings.min_end_marker_size > settings.max_end_marker_size { - settings.max_end_marker_size = settings.min_end_marker_size; - } - if settings.min_start_marker_count > settings.max_start_marker_count { - settings.max_start_marker_count = settings.min_start_marker_count; - } - if settings.min_end_marker_count > settings.max_end_marker_count { - settings.max_end_marker_count = settings.min_end_marker_count; - } - if settings.min_traps_per_area > settings.max_traps_per_area { - settings.max_traps_per_area = settings.min_traps_per_area; - } - if settings.min_monsters_per_area > settings.max_monsters_per_area { - settings.max_monsters_per_area = settings.min_monsters_per_area; - } - if settings.min_levels == 0 { - settings.min_levels = 1; - } - if settings.min_levels > settings.max_levels { - settings.max_levels = settings.min_levels; - } -} - -impl DungeonApp { - fn clear_hover_targets(&mut self) { - self.hover_room_idx = None; - self.hover_corridor_idx = None; - self.hover_door_idx = None; - self.hover_text_idx = None; - self.hover_marker = None; - self.hover_level_idx = None; - self.hover_stair_idx = None; - } - - fn reset_transient_state(&mut self) { - self.drag_state = None; - self.add_corridor_drag = None; - self.resize_state = None; - self.clear_hover_targets(); - self.pending_delete = false; - } - - fn snapshot(&self) -> AppSnapshot { - AppSnapshot { - settings: self.settings.clone(), - levels: self.levels.clone(), - suppressed_auto_door_edges: self.suppressed_auto_door_edges.clone(), - } - } - - fn push_undo_snapshot(&mut self) { - let snapshot = self.snapshot(); - if self.undo_stack.last() == Some(&snapshot) { - return; - } - self.undo_stack.push(snapshot); - if self.undo_stack.len() > 100 { - self.undo_stack.remove(0); - } - self.redo_stack.clear(); - } - - fn restore_snapshot(&mut self, snapshot: AppSnapshot) { - self.settings = snapshot.settings; - self.levels = snapshot.levels; - self.suppressed_auto_door_edges = snapshot.suppressed_auto_door_edges; - self.reset_transient_state(); - } - - fn undo(&mut self) { - let Some(snapshot) = self.undo_stack.pop() else { - return; - }; - self.redo_stack.push(self.snapshot()); - self.restore_snapshot(snapshot); - } - - fn redo(&mut self) { - let Some(snapshot) = self.redo_stack.pop() else { - return; - }; - self.undo_stack.push(self.snapshot()); - self.restore_snapshot(snapshot); - } - - // Clear the current layout and reset transient interaction state. - fn clear_layout(&mut self) { - if self.settings.active_level_index >= self.levels.len() { - return; - } - self.levels[self.settings.active_level_index] = - DungeonLayout::empty(self.settings.pack_rooms_without_corridors); - self.suppressed_auto_door_edges.clear(); - self.reset_transient_state(); - } - - // Start composition mode with one empty level. - fn enter_composition_layout(&mut self) { - self.levels = vec![DungeonLayout::empty( - self.settings.pack_rooms_without_corridors, - )]; - self.settings.active_level_index = 0; - self.settings.export_level_index = 0; - self.suppressed_auto_door_edges.clear(); - self.reset_transient_state(); - } - - // Regenerate the dungeon layout from current settings and reset drag state. - fn regenerate_layout(&mut self) { - self.drag_state = None; - self.suppressed_auto_door_edges.clear(); - self.levels = generate_all_levels(&self.settings); - self.refresh_stairs(); - if self.settings.active_level_index >= self.levels.len() { - self.settings.active_level_index = 0; - } - } - - // Delete a level by index, with undo support and safeguards. - fn delete_level(&mut self, level_idx: usize) { - // Don't delete if there's only one level remaining. - if self.levels.len() <= 1 { - return; - } - if level_idx >= self.levels.len() { - return; - } - self.push_undo_snapshot(); - self.levels.remove(level_idx); - // Adjust active level index to stay within bounds. - if self.settings.active_level_index >= self.levels.len() { - self.settings.active_level_index = self.levels.len() - 1; - } - self.reset_transient_state(); - } - - // Handle dragging rooms or corridors with the primary mouse button. - fn handle_drag( - &mut self, - ctx: &egui::Context, - response: &egui::Response, - geometry: &GridGeometry, - ) { - if !ctx.input(|i| i.pointer.primary_down()) && !response.drag_started() { - return; - } - if self.settings.add_tool != AddTool::None { - return; - } - if response.drag_started() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some(text_idx) = self.text_at_pointer(pointer_pos, geometry) - { - self.push_undo_snapshot(); - self.drag_state = Some(DragState::Text(TextDragState { text_idx })); - } else if response.drag_started() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some(marker_drag) = self.marker_drag_at_pointer(pointer_pos, geometry) - { - self.push_undo_snapshot(); - self.drag_state = Some(DragState::Marker(marker_drag)); - } else if response.drag_started() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some(stair_drag) = self.stair_drag_at_pointer(pointer_pos, geometry) - { - self.push_undo_snapshot(); - self.drag_state = Some(DragState::Staircase(stair_drag)); - } else if response.drag_started() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some(corridor_drag) = self.corridor_drag_at_pointer(pointer_pos, geometry) - { - self.push_undo_snapshot(); - self.drag_state = Some(DragState::Corridor(corridor_drag)); - } else if response.drag_started() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((room_idx, offset_x, offset_y)) = - self.room_at_pointer(pointer_pos, geometry) - { - self.push_undo_snapshot(); - self.drag_state = Some(DragState::Room(RoomDragState { - room_idx, - offset_x, - offset_y, - })); - } - - if response.dragged() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some(drag_state) = self.drag_state.clone() - { - match drag_state { - DragState::Room(drag) => self.drag_room_to_pointer(drag, pointer_pos, geometry), - DragState::Text(drag) => self.drag_text_to_pointer(drag, pointer_pos, geometry), - DragState::Marker(drag) => self.drag_marker_to_pointer(drag, pointer_pos, geometry), - DragState::Corridor(drag) => { - self.drag_corridor_to_pointer(drag, pointer_pos, geometry) - } - DragState::Staircase(drag) => { - self.drag_stair_to_pointer(drag, pointer_pos, geometry) - } - } - } - - if response.drag_stopped() || !response.ctx.input(|i| i.pointer.primary_down()) { - self.drag_state = None; - } - } - - // Resolve the top-most room under the pointer and return its index and offset. - fn room_at_pointer( - &self, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, - ) -> Option<(usize, f32, f32)> { - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - - if self.settings.active_level_index >= self.levels.len() { - return None; - } - let layout = &self.levels[self.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 - } - - // Move a room to follow the pointer and reroute its corridors. - fn drag_room_to_pointer( - &mut self, - drag: RoomDragState, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, - ) { - let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { - return; - }; - - if self.settings.active_level_index >= self.levels.len() { - return; - } - - if drag.room_idx >= self.levels[self.settings.active_level_index].rooms.len() { - self.drag_state = None; - return; - } - - let (width, height, old_x, old_y) = { - let room = &self.levels[self.settings.active_level_index].rooms[drag.room_idx]; - (room.width, room.height, room.x, room.y) - }; - let max_x = self.settings.cols.saturating_sub(width); - let max_y = self.settings.rows.saturating_sub(height); - - let target_x = (grid_x - drag.offset_x).floor().max(0.0) as usize; - let target_y = (grid_y - drag.offset_y).floor().max(0.0) as usize; - let new_x = target_x.min(max_x); - let new_y = target_y.min(max_y); - - if new_x == old_x && new_y == old_y { - return; - } - - self.levels[self.settings.active_level_index].rooms[drag.room_idx].x = new_x; - self.levels[self.settings.active_level_index].rooms[drag.room_idx].y = new_y; - self.reroute_corridors_for_room(drag.room_idx); - self.refresh_doors(); - } - - fn drag_text_to_pointer( - &mut self, - drag: TextDragState, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, - ) { - let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { - return; - }; - - if self.settings.active_level_index >= self.levels.len() { - return; - } - - if drag.text_idx - >= self.levels[self.settings.active_level_index] - .text_labels - .len() - { - self.drag_state = None; - return; - } - - let target = ( - (grid_x.floor().max(0.0) as usize).min(self.settings.cols.saturating_sub(1)), - (grid_y.floor().max(0.0) as usize).min(self.settings.rows.saturating_sub(1)), - ); - - self.levels[self.settings.active_level_index].text_labels[drag.text_idx].cell = target; - } - - // Recalculate corridor paths connected to a moved room. - fn reroute_corridors_for_room(&mut self, room_idx: usize) { - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &mut self.levels[self.settings.active_level_index]; - - if layout.packed_rooms { - return; - } - - for corridor in &mut layout.corridors { - if corridor.start_room_id != room_idx && corridor.end_room_id != room_idx { - continue; - } - - let start = layout.rooms[corridor.start_room_id].center_cell(); - let end = layout.rooms[corridor.end_room_id].center_cell(); - let blocked = blocked_room_cells( - &layout.rooms, - &[corridor.start_room_id, corridor.end_room_id], - ); - if let Some(path) = - shortest_path_cells(start, end, self.settings.cols, self.settings.rows, &blocked) - { - corridor.path = path; - } - } - } - - // Capture corridor drag state when the pointer is over a corridor cell. - fn corridor_drag_at_pointer( - &self, - 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 self.settings.active_level_index >= self.levels.len() { - return None; - } - let layout = &self.levels[self.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, - }) - } - - // Reroute a corridor path through the current pointer cell. - fn drag_corridor_to_pointer( - &mut self, - drag: CorridorDragState, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, - ) { - let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { - return; - }; - - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &mut self.levels[self.settings.active_level_index]; - - if drag.corridor_index >= layout.corridors.len() { - self.drag_state = None; - return; - } - let corridor = &layout.corridors[drag.corridor_index]; - - let target = ( - (grid_x.floor().max(0.0) as usize).min(self.settings.cols.saturating_sub(1)), - (grid_y.floor().max(0.0) as usize).min(self.settings.rows.saturating_sub(1)), - ); - - let Some(new_path) = reroute_path_through_cell( - &drag.original_path, - drag.original_cell, - target, - self.settings.cols, - self.settings.rows, - &blocked_room_cells( - &layout.rooms, - &[corridor.start_room_id, corridor.end_room_id], - ), - ) else { - return; - }; - - layout.corridors[drag.corridor_index].path = new_path; - self.refresh_doors(); - } - - // Rebuild doors based on the current layout and settings. - fn refresh_level(&mut self, level_idx: usize) { - if level_idx >= self.levels.len() { - return; - } - - // Reroute all corridors to match resized/moved rooms. - let layout = &mut self.levels[level_idx]; - if !layout.packed_rooms { - let rooms = layout.rooms.clone(); - for corridor in &mut layout.corridors { - let start = rooms[corridor.start_room_id].center_cell(); - let end = rooms[corridor.end_room_id].center_cell(); - let blocked = - blocked_room_cells(&rooms, &[corridor.start_room_id, corridor.end_room_id]); - if let Some(path) = shortest_path_cells( - start, - end, - self.settings.cols, - self.settings.rows, - &blocked, - ) { - corridor.path = path; - } - } - } - - // Rebuild doors. - let door_settings = door_settings_from_ui(&self.settings); - layout::apply_doors( - layout, - self.settings.seed, - door_settings, - self.settings.cols, - self.settings.rows, - ); - layout.doors.retain(|door| { - !door_edges_for(door, self.settings.cols, self.settings.rows) - .iter() - .any(|edge| self.suppressed_auto_door_edges.contains(edge)) - }); - - // Rebuild windows. - let window_settings = window_settings_from_ui(&self.settings); - layout::apply_windows( - layout, - self.settings.seed, - window_settings, - self.settings.cols, - self.settings.rows, - ); - } - - // Sync stair placement across all generated levels. - fn refresh_stairs(&mut self) { - let results = populate_stairs(self.levels.clone(), &self.settings); - self.levels = results.iter().map(|(layout, _)| layout.clone()).collect(); - - for (level_idx, (_, modified)) in results.into_iter().enumerate() { - if modified { - self.refresh_level(level_idx); - } - } - } - - fn refresh_doors(&mut self) { - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &mut self.levels[self.settings.active_level_index]; - - layout::apply_doors( - layout, - self.settings.seed, - door_settings_from_ui(&self.settings), - self.settings.cols, - self.settings.rows, - ); - layout.doors.retain(|door| { - !door_edges_for(door, self.settings.cols, self.settings.rows) - .iter() - .any(|edge| self.suppressed_auto_door_edges.contains(edge)) - }); - layout::apply_windows( - layout, - self.settings.seed, - window_settings_from_ui(&self.settings), - self.settings.cols, - self.settings.rows, - ); - } - - // Handle add-tool interactions for rooms, corridors, and doors. - fn handle_add_tool(&mut self, response: &egui::Response, geometry: &GridGeometry) { - if self.settings.add_tool != AddTool::None && response.secondary_clicked() { - self.settings.add_tool = AddTool::None; - self.add_corridor_drag = None; - return; - } - - match self.settings.add_tool { - AddTool::Room => { - self.add_corridor_drag = None; - if response.clicked() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) - { - let cell = (grid_x.floor() as usize, grid_y.floor() as usize); - self.add_room_at_cell(cell); - } - } - AddTool::Corridor => { - if response.drag_started() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) - { - let cell = (grid_x.floor() as usize, grid_y.floor() as usize); - self.add_corridor_drag = Some(AddCorridorDrag { - start_cell: cell, - current_cell: cell, - }); - } - - if response.dragged() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) - && let Some(drag) = &mut self.add_corridor_drag - { - drag.current_cell = (grid_x.floor() as usize, grid_y.floor() as usize); - } - - if response.drag_stopped() { - if let Some(drag) = self.add_corridor_drag.take() { - self.add_corridor_between(drag.start_cell, drag.current_cell); - } - } - } - AddTool::Staircase => { - self.add_corridor_drag = None; - if response.clicked() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) - { - let cell = (grid_x.floor() as usize, grid_y.floor() as usize); - self.add_staircase_at_cell(cell); - } - } - AddTool::Archway | AddTool::Door | AddTool::LockedDoor | AddTool::SecretDoor => { - self.add_corridor_drag = None; - if response.clicked() - && let Some(pointer_pos) = response.interact_pointer_pos() - { - let kind = match self.settings.add_tool { - AddTool::Archway => ManualDoorKind::Archway, - AddTool::Door => ManualDoorKind::Regular, - AddTool::LockedDoor => ManualDoorKind::Locked, - AddTool::SecretDoor => ManualDoorKind::Secret, - _ => unreachable!(), - }; - self.add_door_at_pointer(pointer_pos, geometry, kind); - } - } - AddTool::Text => { - self.add_corridor_drag = None; - if response.clicked() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) - { - let cell = (grid_x.floor() as usize, grid_y.floor() as usize); - self.add_text_at_cell(cell); - } - } - AddTool::StartMarker - | AddTool::EndMarker - | AddTool::TrapMarker - | AddTool::MonsterMarker => { - self.add_corridor_drag = None; - if response.clicked() - && let Some(pointer_pos) = response.interact_pointer_pos() - && let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) - { - let cell = (grid_x.floor() as usize, grid_y.floor() as usize); - let kind = match self.settings.add_tool { - AddTool::StartMarker => MarkerKind::Start, - AddTool::EndMarker => MarkerKind::End, - AddTool::TrapMarker => MarkerKind::Trap, - AddTool::MonsterMarker => MarkerKind::Monster, - _ => unreachable!(), - }; - self.add_marker_at_cell(cell, kind); - } - } - AddTool::None => { - self.add_corridor_drag = None; - } - } - } - - // Draw the overlay showing the corridor add drag preview. - fn draw_add_overlay(&self, painter: &egui::Painter, geometry: &GridGeometry) { - if self.settings.add_tool != AddTool::Corridor { - return; - } - let Some(drag) = &self.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); - draw_dashed_line(painter, start, end, stroke, 8.0, 6.0); - } - - // Draw ghost preview for the currently active add tool. - fn draw_add_tool_ghost(&self, painter: &egui::Painter, geometry: &GridGeometry) { - let tool = self.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 = self.settings.add_room_width.max(1); - let height = self.settings.add_room_height.max(1); - let max_x = self.settings.cols.saturating_sub(cell.0); - let max_y = self.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 = self.settings.add_text_value.trim(); - if text.is_empty() { - return; - } - let font_size = self.settings.add_text_font_size.max(8); - let rect = 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 = self.settings.add_marker_size.max(1); - let origin = - centered_marker_origin(cell, size, self.settings.cols, self.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 = self.settings.add_stair_width.max(1); - let height = self.settings.add_stair_height.max(1); - let max_x = self.settings.cols.saturating_sub(width); - let max_y = self.settings.rows.saturating_sub(height); - 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 => { - // Show ghost preview on the hovered door edge. - if let Some((edge_a, edge_b)) = self.door_edge_at_pointer(pointer_pos, geometry) { - let door_width_cells = 1; - let style = match tool { - AddTool::Archway => DoorLineStyle::Dotted, - AddTool::Door => DoorLineStyle::LongDash, - AddTool::LockedDoor => DoorLineStyle::ShortDash, - AddTool::SecretDoor => DoorLineStyle::DashDotDot, - _ => 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); - 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 => {} - } - } - - // Add a room anchored to the given grid cell if space allows. - fn add_room_at_cell(&mut self, cell: (usize, usize)) { - self.push_undo_snapshot(); - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &mut self.levels[self.settings.active_level_index]; - - let width = self.settings.add_room_width.max(1); - let height = self.settings.add_room_height.max(1); - if cell.0 >= self.settings.cols || cell.1 >= self.settings.rows { - return; - } - let max_w = self.settings.cols.saturating_sub(cell.0); - let max_h = self.settings.rows.saturating_sub(cell.1); - let width = width.min(max_w); - let height = height.min(max_h); - if width == 0 || height == 0 { - return; - } - - let new_room = layout::Room { - x: cell.0, - y: cell.1, - width, - height, - }; - - if layout - .rooms - .iter() - .any(|room| rooms_overlap(&new_room, room)) - { - return; - } - - layout.rooms.push(new_room); - self.refresh_doors(); - } - - // Add a corridor between two room cells using a shortest path. - fn add_corridor_between(&mut self, start: (usize, usize), end: (usize, usize)) { - self.push_undo_snapshot(); - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &mut self.levels[self.settings.active_level_index]; - - if start == end { - return; - } - - let start_room = room_index_at_cell(&layout.rooms, start); - let end_room = room_index_at_cell(&layout.rooms, end); - let (Some(start_room), Some(end_room)) = (start_room, end_room) else { - return; - }; - - let blocked = blocked_room_cells(&layout.rooms, &[start_room, end_room]); - - let Some(path) = - shortest_path_cells(start, end, self.settings.cols, self.settings.rows, &blocked) - else { - return; - }; - - let next_id = layout - .corridors - .iter() - .map(|c| c.id) - .max() - .unwrap_or(0) - .wrapping_add(1); - let width = self.settings.add_corridor_width.max(1); - - layout.corridors.push(layout::Corridor { - id: next_id, - start_room_id: start_room, - end_room_id: end_room, - path, - width, - }); - self.refresh_doors(); - } - - fn add_text_at_cell(&mut self, cell: (usize, usize)) { - self.push_undo_snapshot(); - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &mut self.levels[self.settings.active_level_index]; - - if cell.0 >= self.settings.cols || cell.1 >= self.settings.rows { - return; - } - - let text = self.settings.add_text_value.trim().to_string(); - if text.is_empty() { - return; - } - - if let Some(existing) = layout - .text_labels - .iter_mut() - .find(|label| label.cell == cell) - { - existing.text = text; - return; - } - - layout.text_labels.push(layout::TextLabel { - cell, - text, - font_size: self.settings.add_text_font_size, - }); - } - - fn add_marker_at_cell(&mut self, cell: (usize, usize), kind: MarkerKind) { - self.push_undo_snapshot(); - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &mut self.levels[self.settings.active_level_index]; - - if cell.0 >= self.settings.cols || cell.1 >= self.settings.rows { - return; - } - - let size = self.settings.add_marker_size.max(1); - let origin = centered_marker_origin(cell, size, self.settings.cols, self.settings.rows); - let marker = layout::AreaMarker { cell: origin, size }; - - match kind { - MarkerKind::Start => layout.start_markers.push(marker), - MarkerKind::End => layout.end_markers.push(marker), - MarkerKind::Trap => layout.trap_markers.push(marker), - MarkerKind::Monster => layout.monster_markers.push(marker), - } - } - - fn add_staircase_at_cell(&mut self, cell: (usize, usize)) { - self.push_undo_snapshot(); - if self.settings.active_level_index >= self.levels.len() { - return; - } - - if cell.0 >= self.settings.cols || cell.1 >= self.settings.rows { - return; - } - - let width = self.settings.add_stair_width.max(1); - let height = self.settings.add_stair_height.max(1); - let max_x = self.settings.cols.saturating_sub(width); - let max_y = self.settings.rows.saturating_sub(height); - let origin_x = cell.0.min(max_x); - let origin_y = cell.1.min(max_y); - - let stair = layout::Staircase { - cell: (origin_x, origin_y), - width, - height, - }; - - if self.settings.sync_stairs_across_levels { - // Add stairs to ALL levels at the same position. - for layout in &mut self.levels { - layout.stairs.push(stair.clone()); - } - } else { - // Add stairs to the active level and the adjacent level above (or below for top level). - let active_idx = self.settings.active_level_index; - let layout = &mut self.levels[active_idx]; - layout.stairs.push(stair.clone()); - - // Determine which adjacent level to add the stair to. - // Stairs connect two adjacent floors, so we add them to the level above - // (or below if this is the top level). - if active_idx + 1 < self.levels.len() { - let above_layout = &mut self.levels[active_idx + 1]; - above_layout.stairs.push(stair); - } else if active_idx > 0 { - // Top level: connect to the level below - let below_layout = &mut self.levels[active_idx - 1]; - below_layout.stairs.push(stair); - } - } - } - - // Update which room/corridor/door is currently hovered. - fn update_hover_targets(&mut self, ctx: &egui::Context, geometry: &GridGeometry) { - if self.drag_state.is_some() - || self.resize_state.is_some() - || self.add_corridor_drag.is_some() - { - return; - } - - let Some(pointer_pos) = ctx.pointer_hover_pos() else { - self.clear_hover_targets(); - return; - }; - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &self.levels[self.settings.active_level_index]; - - if let Some(edge) = self.door_edge_at_pointer(pointer_pos, geometry) { - let target = normalized_edge(edge.0, edge.1); - self.hover_door_idx = layout.doors.iter().position(|d| { - door_edges_for(d, self.settings.cols, self.settings.rows) - .iter() - .any(|e| normalized_edge(e.0, e.1) == target) - }); - if self.hover_door_idx.is_some() { - self.hover_room_idx = None; - self.hover_corridor_idx = None; - self.hover_text_idx = None; - self.hover_marker = None; - return; - } - } - - self.hover_text_idx = self.text_at_pointer(pointer_pos, geometry); - if self.hover_text_idx.is_some() { - self.hover_room_idx = None; - self.hover_corridor_idx = None; - self.hover_door_idx = None; - self.hover_marker = None; - return; - } - - self.hover_marker = self.marker_at_pointer(pointer_pos, geometry); - if self.hover_marker.is_some() { - self.hover_room_idx = None; - self.hover_corridor_idx = None; - self.hover_door_idx = None; - self.hover_stair_idx = None; - return; - } - - self.hover_stair_idx = self.stair_at_pointer(pointer_pos, geometry); - if self.hover_stair_idx.is_some() { - self.hover_room_idx = None; - self.hover_corridor_idx = None; - self.hover_door_idx = None; - self.hover_marker = None; - return; - } - - if let Some((room_idx, _, _)) = self.room_at_pointer(pointer_pos, geometry) { - self.hover_room_idx = Some(room_idx); - self.hover_corridor_idx = None; - self.hover_door_idx = None; - self.hover_marker = None; - } else { - self.hover_room_idx = None; - if let Some(corridor_drag) = self.corridor_drag_at_pointer(pointer_pos, geometry) { - self.hover_corridor_idx = Some(corridor_drag.corridor_index); - self.hover_door_idx = None; - self.hover_marker = None; - } else { - self.hover_corridor_idx = None; - self.hover_door_idx = None; - self.hover_marker = None; - } - } - } - - fn text_at_pointer(&self, 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 self.settings.active_level_index >= self.levels.len() { - return None; - } - let layout = &self.levels[self.settings.active_level_index]; - - layout - .text_labels - .iter() - .position(|label| label.cell == clicked) - } - - fn marker_at_pointer( - &self, - 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 self.settings.active_level_index >= self.levels.len() { - return None; - } - let layout = &self.levels[self.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)) - }) - } - - fn marker_drag_at_pointer( - &self, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, - ) -> Option { - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - if self.settings.active_level_index >= self.levels.len() { - return None; - } - let layout = &self.levels[self.settings.active_level_index]; - - match self.marker_at_pointer(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, - }) - } - } - } - - fn stair_at_pointer(&self, 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 self.settings.active_level_index >= self.levels.len() { - return None; - } - let layout = &self.levels[self.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 - }) - } - - fn stair_drag_at_pointer( - &self, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, - ) -> Option { - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - if self.settings.active_level_index >= self.levels.len() { - return None; - } - let stair_idx = self.stair_at_pointer(pointer_pos, geometry)?; - let layout = &self.levels[self.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, - }) - } - - fn drag_marker_to_pointer( - &mut self, - drag: MarkerDragState, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, - ) { - let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { - return; - }; - - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &mut self.levels[self.settings.active_level_index]; - - let markers = match drag.kind { - MarkerKind::Start => &mut layout.start_markers, - MarkerKind::End => &mut layout.end_markers, - MarkerKind::Trap => &mut layout.trap_markers, - MarkerKind::Monster => &mut layout.monster_markers, - }; - let Some(marker) = markers.get_mut(drag.marker_idx) else { - self.drag_state = None; - return; - }; - - let max_x = self.settings.cols.saturating_sub(marker.size); - let max_y = self.settings.rows.saturating_sub(marker.size); - let target_x = (grid_x - drag.offset_x).floor().max(0.0) as usize; - let target_y = (grid_y - drag.offset_y).floor().max(0.0) as usize; - marker.cell = (target_x.min(max_x), target_y.min(max_y)); - } - - fn drag_stair_to_pointer( - &mut self, - drag: StaircaseDragState, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, - ) { - let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else { - return; - }; - - if self.settings.active_level_index >= self.levels.len() { - return; - } - - let (stair_width, stair_height) = { - let layout = &self.levels[self.settings.active_level_index]; - let Some(stair) = layout.stairs.get(drag.stair_idx) else { - return; - }; - (stair.width, stair.height) - }; - - let max_x = self.settings.cols.saturating_sub(stair_width); - let max_y = self.settings.rows.saturating_sub(stair_height); - let target_x = (grid_x - drag.offset_x).floor().max(0.0) as usize; - let target_y = (grid_y - drag.offset_y).floor().max(0.0) as usize; - let new_cell = (target_x.min(max_x), target_y.min(max_y)); - - if self.settings.sync_stairs_across_levels { - // Move ALL stairs that were at the original location across ALL levels. - for layout in &mut self.levels { - for stair in &mut layout.stairs { - if stair.cell == drag.original_cell { - stair.cell = new_cell; - } - } - } - } else { - // Move only the linked stairs (current level and adjacent levels if positions match). - let current_idx = self.settings.active_level_index; - let level_indices = vec![ - Some(current_idx), - current_idx.checked_sub(1), - if current_idx + 1 < self.levels.len() { - Some(current_idx + 1) - } else { - None - }, - ]; - - for idx in level_indices.into_iter().flatten() { - let layout = &mut self.levels[idx]; - for stair in &mut layout.stairs { - if stair.cell == drag.original_cell { - stair.cell = new_cell; - } - } - } - } - - // Update the drag state with the new position to allow continuous dragging. - if let Some(DragState::Staircase(ref mut d)) = self.drag_state { - d.original_cell = new_cell; - } - } - - // Handle right-click resizing of rooms and return whether resizing is active. - fn handle_resize( - &mut self, - ctx: &egui::Context, - response: &egui::Response, - geometry: &GridGeometry, - ) -> bool { - if self.settings.add_tool != AddTool::None { - return false; - } - - if ctx.input(|i| i.pointer.secondary_pressed()) && self.resize_state.is_none() { - if let Some(pointer_pos) = response.interact_pointer_pos() { - if let Some(text_idx) = self.text_at_pointer(pointer_pos, geometry) { - if let Some(state) = self.start_text_resize(text_idx) { - self.push_undo_snapshot(); - self.resize_state = Some(ResizeState::Text(state)); - return true; - } - } else if let Some(stair_idx) = self.stair_at_pointer(pointer_pos, geometry) { - if let Some(state) = - self.start_staircase_resize(stair_idx, pointer_pos, geometry) - { - self.push_undo_snapshot(); - self.resize_state = Some(ResizeState::Staircase(state)); - return true; - } - } else if let Some(room_idx) = self.resize_room_at_pointer(pointer_pos, geometry) { - if let Some(state) = self.start_resize(room_idx, pointer_pos, geometry) { - self.push_undo_snapshot(); - self.resize_state = Some(ResizeState::Room(state)); - return true; - } - } - } - } - - if let Some(state) = self.resize_state.clone() { - if response.dragged_by(egui::PointerButton::Secondary) - && ctx.input(|i| i.pointer.secondary_down()) - && let Some(pointer_pos) = response.interact_pointer_pos() - { - if let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) { - let cell = (grid_x.floor() as usize, grid_y.floor() as usize); - match state { - ResizeState::Room(state) => self.resize_room(&state, cell), - ResizeState::Text(state) => self.resize_text(&state, cell), - ResizeState::Staircase(state) => self.resize_staircase(&state, cell), - } - } - } - - if ctx.input(|i| i.pointer.button_released(egui::PointerButton::Secondary)) { - self.resize_state = None; - } - return true; - } - - false - } - - fn start_text_resize(&self, text_idx: usize) -> Option { - if self.settings.active_level_index >= self.levels.len() { - return None; - } - let layout = &self.levels[self.settings.active_level_index]; - - let label = layout.text_labels.get(text_idx)?; - Some(TextResizeState { - text_idx, - origin_cell: label.cell, - original_font_size: label.font_size, - }) - } - - // Resolve the room that should respond to a resize click, including outer corner targets. - fn resize_room_at_pointer( - &self, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, - ) -> Option { - if let Some((room_idx, _, _)) = self.room_at_pointer(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 self.settings.active_level_index >= self.levels.len() { - return None; - } - let layout = &self.levels[self.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 - } - - // Pick the resize corner from the initial click quadrant and keep the opposite corner fixed. - fn start_resize( - &self, - room_idx: usize, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, - ) -> Option { - if self.settings.active_level_index >= self.levels.len() { - return None; - } - let layout = &self.levels[self.settings.active_level_index]; - - let original_room = layout.rooms.get(room_idx)?.clone(); - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - let center_x = original_room.x as f32 + original_room.width as f32 * 0.5; - let center_y = original_room.y as f32 + original_room.height as f32 * 0.5; - let pointer_x = grid_x; - let pointer_y = grid_y; - - let resize_corner = match (pointer_x >= center_x, pointer_y >= center_y) { - (false, false) => ResizeCorner::TopLeft, - (true, false) => ResizeCorner::TopRight, - (false, true) => ResizeCorner::BottomLeft, - (true, true) => ResizeCorner::BottomRight, - }; - - Some(RoomResizeState { - room_idx, - original_room, - resize_corner, - }) - } - - fn start_staircase_resize( - &self, - stair_idx: usize, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, - ) -> Option { - if self.settings.active_level_index >= self.levels.len() { - return None; - } - let layout = &self.levels[self.settings.active_level_index]; - - let original_stair = layout.stairs.get(stair_idx)?.clone(); - let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?; - let center_x = original_stair.cell.0 as f32 + original_stair.width as f32 * 0.5; - let center_y = original_stair.cell.1 as f32 + original_stair.height as f32 * 0.5; - let pointer_x = grid_x; - let pointer_y = grid_y; - - let resize_corner = match (pointer_x >= center_x, pointer_y >= center_y) { - (false, false) => ResizeCorner::TopLeft, - (true, false) => ResizeCorner::TopRight, - (false, true) => ResizeCorner::BottomLeft, - (true, true) => ResizeCorner::BottomRight, - }; - - Some(StaircaseResizeState { - stair_idx, - original_stair, - resize_corner, - }) - } - - // Resize only the corner chosen at drag start while keeping the opposite corner fixed. - fn resize_room(&mut self, state: &RoomResizeState, target_cell: (usize, usize)) { - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &mut self.levels[self.settings.active_level_index]; - - let cols = self.settings.cols.max(1); - let rows = self.settings.rows.max(1); - - let tx = target_cell.0.min(cols - 1) as isize; - let ty = target_cell.1.min(rows - 1) as isize; - let original = &state.original_room; - - let left = original.x as isize; - let top = original.y as isize; - let right = (original.x + original.width - 1) as isize; - let bottom = (original.y + original.height - 1) as isize; - let (x0, x1, y0, y1) = match state.resize_corner { - ResizeCorner::TopLeft => (tx.min(right), right, ty.min(bottom), bottom), - ResizeCorner::TopRight => (left, tx.max(left), ty.min(bottom), bottom), - ResizeCorner::BottomLeft => (tx.min(right), right, top, ty.max(top)), - ResizeCorner::BottomRight => (left, tx.max(left), top, ty.max(top)), - }; - - let new_room = layout::Room { - x: x0 as usize, - y: y0 as usize, - width: (x1 - x0 + 1) as usize, - height: (y1 - y0 + 1) as usize, - }; - - if new_room.width == 0 || new_room.height == 0 { - return; - } - - if new_room.x + new_room.width > cols || new_room.y + new_room.height > rows { - return; - } - - if layout - .rooms - .iter() - .enumerate() - .any(|(idx, room)| idx != state.room_idx && rooms_overlap(&new_room, room)) - { - return; - } - - if let Some(room) = layout.rooms.get_mut(state.room_idx) { - room.x = new_room.x; - room.y = new_room.y; - room.width = new_room.width; - room.height = new_room.height; - self.reroute_corridors_for_room(state.room_idx); - self.refresh_doors(); - } - } - - fn resize_text(&mut self, state: &TextResizeState, target_cell: (usize, usize)) { - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &mut self.levels[self.settings.active_level_index]; - - let Some(label) = layout.text_labels.get_mut(state.text_idx) else { - self.resize_state = None; - return; - }; - - let dx = target_cell.0 as isize - state.origin_cell.0 as isize; - let dy = target_cell.1 as isize - state.origin_cell.1 as isize; - let delta_cells = if dx.abs() >= dy.abs() { dx } else { -dy }; - let new_size = (state.original_font_size as isize + (delta_cells * 4)).clamp(8, 96); - label.font_size = new_size as u16; - } - - fn resize_staircase(&mut self, state: &StaircaseResizeState, target_cell: (usize, usize)) { - if self.settings.active_level_index >= self.levels.len() { - return; - } - - let cols = self.settings.cols.max(1); - let rows = self.settings.rows.max(1); - - let tx = target_cell.0.min(cols - 1) as isize; - let ty = target_cell.1.min(rows - 1) as isize; - let original = &state.original_stair; - - let left = original.cell.0 as isize; - let top = original.cell.1 as isize; - let right = (original.cell.0 + original.width - 1) as isize; - let bottom = (original.cell.1 + original.height - 1) as isize; - - let (x0, x1, y0, y1) = match state.resize_corner { - ResizeCorner::TopLeft => (tx.min(right), right, ty.min(bottom), bottom), - ResizeCorner::TopRight => (left, tx.max(left), ty.min(bottom), bottom), - ResizeCorner::BottomLeft => (tx.min(right), right, top, ty.max(top)), - ResizeCorner::BottomRight => (left, tx.max(left), top, ty.max(top)), - }; - - let new_stair_cell = (x0 as usize, y0 as usize); - let new_stair_width = (x1 - x0 + 1) as usize; - let new_stair_height = (y1 - y0 + 1) as usize; - - if new_stair_width == 0 || new_stair_height == 0 { - return; - } - - // Check for collisions with other stairs on the current level. - let active_layout = &self.levels[self.settings.active_level_index]; - if active_layout.stairs.iter().enumerate().any(|(idx, stair)| { - idx != state.stair_idx - && rects_overlap( - new_stair_cell.0, - new_stair_cell.1, - new_stair_width, - new_stair_height, - stair.cell.0, - stair.cell.1, - stair.width, - stair.height, - ) - }) { - return; - } - - if self.settings.sync_stairs_across_levels { - for layout in &mut self.levels { - for stair in &mut layout.stairs { - if stair.cell == state.original_stair.cell { - stair.cell = new_stair_cell; - stair.width = new_stair_width; - stair.height = new_stair_height; - } - } - } - } else { - let current_idx = self.settings.active_level_index; - let level_indices = vec![ - Some(current_idx), - current_idx.checked_sub(1), - if current_idx + 1 < self.levels.len() { - Some(current_idx + 1) - } else { - None - }, - ]; - - for idx in level_indices.into_iter().flatten() { - let layout = &mut self.levels[idx]; - for stair in &mut layout.stairs { - if stair.cell == state.original_stair.cell { - stair.cell = new_stair_cell; - stair.width = new_stair_width; - stair.height = new_stair_height; - } - } - } - } - - // Update the resize state with the new dimensions to allow continuous resizing. - if let Some(ResizeState::Staircase(ref mut s)) = self.resize_state { - s.original_stair.cell = new_stair_cell; - s.original_stair.width = new_stair_width; - s.original_stair.height = new_stair_height; - } - } - - // Draw dashed overlays for room resizing interactions. - fn draw_resize_overlay(&self, painter: &egui::Painter, geometry: &GridGeometry) { - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &self.levels[self.settings.active_level_index]; - - // 1. Check active ResizeState first to lock visual feedback to the being-resized object. - if let Some(state) = &self.resize_state { - match state { - ResizeState::Text(state) => { - if let Some(label) = layout.text_labels.get(state.text_idx) { - let rect = 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) { - self.draw_room_resize_visuals(painter, geometry, room, true); - } - } - ResizeState::Staircase(state) => { - if let Some(stair) = layout.stairs.get(state.stair_idx) { - self.draw_staircase_resize_visuals(painter, geometry, stair, true); - } - } - } - return; - } - - // 2. Fall back to hover state if not resizing. - if let Some(text_idx) = self.hover_text_idx { - if let Some(label) = layout.text_labels.get(text_idx) { - let rect = 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) = self.hover_room_idx { - if let Some(room) = layout.rooms.get(room_idx) { - self.draw_room_resize_visuals(painter, geometry, room, false); - } - } else if let Some(stair_idx) = self.hover_stair_idx { - if let Some(stair) = layout.stairs.get(stair_idx) { - self.draw_staircase_resize_visuals(painter, geometry, stair, false); - } - } - } - - fn draw_room_resize_visuals( - &self, - 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); - - draw_dashed_line(painter, tl, tr, stroke, 6.0, 6.0); - draw_dashed_line(painter, tr, br, stroke, 6.0, 6.0); - draw_dashed_line(painter, br, bl, stroke, 6.0, 6.0); - 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); - } - } - - fn draw_staircase_resize_visuals( - &self, - 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); - - draw_dashed_line(painter, tl, tr, stroke, 6.0, 6.0); - draw_dashed_line(painter, tr, br, stroke, 6.0, 6.0); - draw_dashed_line(painter, br, bl, stroke, 6.0, 6.0); - 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); - } - } - - // Draw a dashed overlay along the hovered corridor path. - fn draw_corridor_hover_overlay(&self, painter: &egui::Painter, geometry: &GridGeometry) { - let Some(idx) = self.hover_corridor_idx else { - return; - }; - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &self.levels[self.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); - draw_dashed_line(painter, a, b, stroke, 6.0, 6.0); - } - } - - // Draw a dashed overlay along the hovered door. - fn draw_door_hover_overlay(&self, painter: &egui::Painter, geometry: &GridGeometry) { - let Some(idx) = self.hover_door_idx else { - return; - }; - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &self.levels[self.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); - draw_door_line( - painter, - geometry, - door.from, - door.to, - door_render_width(door), - stroke, - DoorLineStyle::LongDash, - ); - } - - // Delete the door, room, or corridor at the pointer position. - fn delete_at_pointer(&mut self, pointer_pos: egui::Pos2, geometry: &GridGeometry) { - if self.settings.active_level_index >= self.levels.len() { - return; - } - - if let Some(idx) = self.text_at_pointer(pointer_pos, geometry) { - self.push_undo_snapshot(); - let layout = &mut self.levels[self.settings.active_level_index]; - if idx < layout.text_labels.len() { - layout.text_labels.remove(idx); - } - return; - } - - if let Some(marker) = self.marker_at_pointer(pointer_pos, geometry) { - self.push_undo_snapshot(); - let layout = &mut self.levels[self.settings.active_level_index]; - match marker { - HoverMarker::Start(idx) => { - if idx < layout.start_markers.len() { - layout.start_markers.remove(idx); - } - } - HoverMarker::End(idx) => { - if idx < layout.end_markers.len() { - layout.end_markers.remove(idx); - } - } - HoverMarker::Trap(idx) => { - if idx < layout.trap_markers.len() { - layout.trap_markers.remove(idx); - } - } - HoverMarker::Monster(idx) => { - if idx < layout.monster_markers.len() { - layout.monster_markers.remove(idx); - } - } - } - return; - } - - if let Some(edge) = self.door_edge_at_pointer(pointer_pos, geometry) { - let target = normalized_edge(edge.0, edge.1); - let active_index = self.settings.active_level_index; - if let Some(idx) = self.levels[active_index].doors.iter().position(|d| { - door_edges_for(d, self.settings.cols, self.settings.rows) - .iter() - .any(|e| normalized_edge(e.0, e.1) == target) - }) { - self.push_undo_snapshot(); - let layout = &mut self.levels[self.settings.active_level_index]; - if !layout.doors[idx].manual { - for edge in - door_edges_for(&layout.doors[idx], self.settings.cols, self.settings.rows) - { - self.suppressed_auto_door_edges.insert(edge); - } - layout.doors.remove(idx); - return; - } - - layout.doors.remove(idx); - return; - } - } - - if let Some(stair_idx) = self.stair_at_pointer(pointer_pos, geometry) { - let original_cell = { - let layout = &self.levels[self.settings.active_level_index]; - layout.stairs[stair_idx].cell - }; - - self.push_undo_snapshot(); - if self.settings.sync_stairs_across_levels { - // Remove ALL stairs at this location across ALL levels. - for layout in &mut self.levels { - layout.stairs.retain(|s| s.cell != original_cell); - } - } else { - // Remove linked stairs on current and adjacent levels. - let current_idx = self.settings.active_level_index; - let level_indices = vec![ - Some(current_idx), - current_idx.checked_sub(1), - if current_idx + 1 < self.levels.len() { - Some(current_idx + 1) - } else { - None - }, - ]; - - for idx in level_indices.into_iter().flatten() { - self.levels[idx].stairs.retain(|s| s.cell != original_cell); - } - } - return; - } - - if let Some((room_idx, _, _)) = self.room_at_pointer(pointer_pos, geometry) { - self.push_undo_snapshot(); - self.delete_room(room_idx); - return; - } - - if let Some(corridor_drag) = self.corridor_drag_at_pointer(pointer_pos, geometry) { - let active_index = self.settings.active_level_index; - if corridor_drag.corridor_index < self.levels[active_index].corridors.len() { - self.push_undo_snapshot(); - let layout = &mut self.levels[self.settings.active_level_index]; - layout.corridors.remove(corridor_drag.corridor_index); - self.drag_state = None; - self.refresh_doors(); - } - } - } - - // Remove a room and update corridors/doors to match. - fn delete_room(&mut self, room_idx: usize) { - if self.settings.active_level_index >= self.levels.len() { - return; - } - let layout = &mut self.levels[self.settings.active_level_index]; - - if room_idx >= layout.rooms.len() { - return; - } - layout.rooms.remove(room_idx); - layout - .corridors - .retain(|c| c.start_room_id != room_idx && c.end_room_id != room_idx); - for corridor in &mut layout.corridors { - if corridor.start_room_id > room_idx { - corridor.start_room_id -= 1; - } - if corridor.end_room_id > room_idx { - corridor.end_room_id -= 1; - } - } - self.drag_state = None; - self.refresh_doors(); - } - - // Add or update a door at the pointer edge. - fn add_door_at_pointer( - &mut self, - pointer_pos: egui::Pos2, - geometry: &GridGeometry, - kind: ManualDoorKind, - ) { - if self.settings.active_level_index >= self.levels.len() { - return; - } - - let Some(edge) = self.door_edge_at_pointer(pointer_pos, geometry) else { - return; - }; - - self.push_undo_snapshot(); - let layout = &mut self.levels[self.settings.active_level_index]; - - let target = normalized_edge(edge.0, edge.1); - if let Some(idx) = layout.doors.iter().position(|d| { - door_edges_for(d, self.settings.cols, self.settings.rows) - .iter() - .any(|e| normalized_edge(e.0, e.1) == target) - }) { - let edges = door_edges_for(&layout.doors[idx], self.settings.cols, self.settings.rows); - let mut spawned = Vec::new(); - for edge in edges { - if normalized_edge(edge.0, edge.1) == target { - continue; - } - if layout - .doors - .iter() - .any(|d| normalized_edge(d.from, d.to) == normalized_edge(edge.0, edge.1)) - { - continue; - } - spawned.push(layout::Door { - from: edge.0, - to: edge.1, - width: 1, - span_width: false, - locked: false, - archway: true, - secret: false, - manual: true, - }); - } - let door = &mut layout.doors[idx]; - door.locked = kind == ManualDoorKind::Locked; - door.archway = kind == ManualDoorKind::Archway; - door.secret = kind == ManualDoorKind::Secret; - door.width = 1; - door.span_width = false; - door.manual = true; - for edge in door_edges_for(door, self.settings.cols, self.settings.rows) { - self.suppressed_auto_door_edges.remove(&edge); - } - layout.doors.extend(spawned); - return; - } - - let corridor_cells = corridor_cells(layout, self.settings.cols, self.settings.rows); - let a_room = room_index_at_cell(&layout.rooms, edge.0); - let b_room = room_index_at_cell(&layout.rooms, edge.1); - let a_in_corridor = corridor_cells.contains(&edge.0); - let b_in_corridor = corridor_cells.contains(&edge.1); - - if !manual_door_edge_allowed(a_room, b_room, a_in_corridor, b_in_corridor) { - return; - } - - layout.doors.push(layout::Door { - from: edge.0, - to: edge.1, - width: 1, - span_width: false, - locked: kind == ManualDoorKind::Locked, - archway: kind == ManualDoorKind::Archway, - secret: kind == ManualDoorKind::Secret, - manual: true, - }); - self.suppressed_auto_door_edges.remove(&target); - } - - // Find the nearest cell edge under the pointer for door placement. - fn door_edge_at_pointer( - &self, - 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 >= self.settings.cols as isize - || cell_y >= self.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 >= self.settings.cols as isize - || bx >= self.settings.cols as isize - || ay >= self.settings.rows as isize - || by >= self.settings.rows as isize - { - return None; - } - - Some(((ax as usize, ay as usize), (bx as usize, by as usize))) - } -} - -#[derive(Debug, Clone)] -struct RoomDragState { - room_idx: usize, - offset_x: f32, - offset_y: f32, -} - -#[derive(Debug, Clone)] -struct TextDragState { - text_idx: usize, -} - -#[derive(Debug, Clone)] -struct MarkerDragState { - kind: MarkerKind, - marker_idx: usize, - offset_x: f32, - offset_y: f32, -} - -#[derive(Debug, Clone)] -struct CorridorDragState { - corridor_index: usize, - original_path: Vec<(usize, usize)>, - original_cell: (usize, usize), -} - -#[derive(Debug, Clone)] -struct AddCorridorDrag { - start_cell: (usize, usize), - current_cell: (usize, usize), -} - -#[derive(Debug, Clone, Copy)] -enum ResizeCorner { - TopLeft, - TopRight, - BottomLeft, - BottomRight, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum MarkerKind { - Start, - End, - Trap, - Monster, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum HoverMarker { - Start(usize), - End(usize), - Trap(usize), - Monster(usize), -} - -#[derive(Debug, Clone)] -struct RoomResizeState { - room_idx: usize, - original_room: layout::Room, - resize_corner: ResizeCorner, -} - -#[derive(Debug, Clone)] -enum DragState { - Room(RoomDragState), - Text(TextDragState), - Marker(MarkerDragState), - Corridor(CorridorDragState), - Staircase(StaircaseDragState), -} - -#[derive(Debug, Clone)] -struct StaircaseDragState { - stair_idx: usize, - offset_x: f32, - offset_y: f32, - original_cell: (usize, usize), -} - -#[derive(Debug, Clone)] -struct TextResizeState { - text_idx: usize, - origin_cell: (usize, usize), - original_font_size: u16, -} - -#[derive(Debug, Clone)] -struct StaircaseResizeState { - stair_idx: usize, - original_stair: layout::Staircase, - resize_corner: ResizeCorner, -} - -#[derive(Debug, Clone)] -enum ResizeState { - Room(RoomResizeState), - Text(TextResizeState), - Staircase(StaircaseResizeState), -} - -struct GridGeometry { - rect: egui::Rect, - cell_size: f32, - cols: usize, - rows: usize, -} - -// Draw the grid and return geometry metrics for hit testing. -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, - } -} - -// Render rooms, corridors, walls, and doors in the main canvas. -fn draw_layout( - painter: &egui::Painter, - geometry: &GridGeometry, - layout: &DungeonLayout, - colorblind_mode: bool, - hover_text_idx: Option, - hover_marker: Option, - hover_stair_idx: Option, -) { - let wall_width_px = (geometry.cell_size / 2.5).max(1.0); - let door_width_px = (geometry.cell_size / 5.0).max(1.0); - - let room_fill = Color32::from_rgb(70, 120, 160); - let corridor_fill = Color32::from_rgb(210, 190, 120); - let wall_color = Color32::BLACK; - let corridor_cells_set = corridor_cells(layout, geometry.cols, geometry.rows); - let corridor_edges_set = corridor_edges_from_cells(&corridor_cells_set); - let mut room_cells_set = HashSet::new(); - let room_edges_set = room_edges_from_rooms(&layout.rooms); - let mut door_edges_set = HashSet::new(); - - for door in &layout.doors { - for edge in door_edges_for(door, geometry.cols, geometry.rows) { - door_edges_set.insert(edge); - } - } - - for &(col, row) in &corridor_cells_set { - painter.rect_filled(cell_rect(geometry, col, row), 0.0, corridor_fill); - if colorblind_mode { - painter.circle_filled( - cell_center(geometry, col, row), - geometry.cell_size * 0.14, - Color32::BLACK, - ); - } - } - - for &(col, row) in &corridor_cells_set { - let rect = cell_rect(geometry, col, row); - let right = (col + 1, row); - let bottom = (col, row + 1); - let left = col.checked_sub(1).map(|x| (x, row)); - let top = row.checked_sub(1).map(|y| (col, y)); - - if col == 0 - || (!corridor_cells_set.contains(&(col - 1, row)) - && left - .map(|n| !door_edges_set.contains(&normalized_edge((col, row), n))) - .unwrap_or(true)) - { - draw_wall_segment( - painter, - egui::pos2(rect.left(), rect.top()), - egui::pos2(rect.left(), rect.bottom()), - wall_width_px, - wall_color, - ); - } - if (!corridor_cells_set.contains(&right) - || !corridor_edges_set.contains(&normalized_edge((col, row), right))) - && !door_edges_set.contains(&normalized_edge((col, row), right)) - { - draw_wall_segment( - painter, - egui::pos2(rect.right(), rect.top()), - egui::pos2(rect.right(), rect.bottom()), - wall_width_px, - wall_color, - ); - } - if row == 0 - || (!corridor_cells_set.contains(&(col, row - 1)) - && top - .map(|n| !door_edges_set.contains(&normalized_edge((col, row), n))) - .unwrap_or(true)) - { - draw_wall_segment( - painter, - egui::pos2(rect.left(), rect.top()), - egui::pos2(rect.right(), rect.top()), - wall_width_px, - wall_color, - ); - } - if (!corridor_cells_set.contains(&bottom) - || !corridor_edges_set.contains(&normalized_edge((col, row), bottom))) - && !door_edges_set.contains(&normalized_edge((col, row), bottom)) - { - draw_wall_segment( - painter, - egui::pos2(rect.left(), rect.bottom()), - egui::pos2(rect.right(), rect.bottom()), - wall_width_px, - wall_color, - ); - } - } - - for room in &layout.rooms { - for x in room.x..(room.x + room.width) { - for y in room.y..(room.y + room.height) { - room_cells_set.insert((x, y)); - } - } - - 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 room_rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); - - painter.rect_filled(room_rect, 0.0, room_fill); - if colorblind_mode { - draw_room_crosshatch(painter, geometry, room, Stroke::new(1.5, Color32::BLACK)); - } - } - - for &(col, row) in &room_cells_set { - let rect = cell_rect(geometry, col, row); - let right = (col + 1, row); - let bottom = (col, row + 1); - let left = col.checked_sub(1).map(|x| (x, row)); - let top = row.checked_sub(1).map(|y| (col, y)); - - let left_edge = col == 0 - || (!room_cells_set.contains(&(col - 1, row)) - || left - .map(|n| !room_edges_set.contains(&normalized_edge((col, row), n))) - .unwrap_or(true)) - && left - .map(|n| !door_edges_set.contains(&normalized_edge((col, row), n))) - .unwrap_or(true); - if left_edge { - draw_wall_segment( - painter, - egui::pos2(rect.left(), rect.top()), - egui::pos2(rect.left(), rect.bottom()), - wall_width_px, - wall_color, - ); - } - if (!room_cells_set.contains(&right) - || !room_edges_set.contains(&normalized_edge((col, row), right))) - && !door_edges_set.contains(&normalized_edge((col, row), right)) - { - draw_wall_segment( - painter, - egui::pos2(rect.right(), rect.top()), - egui::pos2(rect.right(), rect.bottom()), - wall_width_px, - wall_color, - ); - } - let top_edge = row == 0 - || (!room_cells_set.contains(&(col, row - 1)) - || top - .map(|n| !room_edges_set.contains(&normalized_edge((col, row), n))) - .unwrap_or(true)) - && top - .map(|n| !door_edges_set.contains(&normalized_edge((col, row), n))) - .unwrap_or(true); - if top_edge { - draw_wall_segment( - painter, - egui::pos2(rect.left(), rect.top()), - egui::pos2(rect.right(), rect.top()), - wall_width_px, - wall_color, - ); - } - if (!room_cells_set.contains(&bottom) - || !room_edges_set.contains(&normalized_edge((col, row), bottom))) - && !door_edges_set.contains(&normalized_edge((col, row), bottom)) - { - draw_wall_segment( - painter, - egui::pos2(rect.left(), rect.bottom()), - egui::pos2(rect.right(), rect.bottom()), - wall_width_px, - wall_color, - ); - } - } - - for door in &layout.doors { - let color = if colorblind_mode { - Color32::BLACK - } else if door.secret { - Color32::from_rgb(170, 80, 170) - } else if door.archway { - Color32::from_rgb(230, 140, 60) - } else if door.locked { - Color32::from_rgb(220, 70, 70) - } else { - Color32::from_rgb(80, 200, 120) - }; - let style = if colorblind_mode { - if door.secret { - DoorLineStyle::DashDotDot - } else if door.archway { - DoorLineStyle::Dotted - } else if door.locked { - DoorLineStyle::ShortDash - } else { - DoorLineStyle::LongDash - } - } else { - DoorLineStyle::Solid - }; - draw_door_line( - painter, - geometry, - door.from, - door.to, - door_render_width(door), - Stroke::new(door_width_px, color), - style, - ); - } - - for window in &layout.windows { - let style = if colorblind_mode { - DoorLineStyle::DashDot - } else { - DoorLineStyle::Solid - }; - draw_window_line( - painter, - geometry, - window, - Stroke::new(door_width_px, Color32::from_rgb(70, 130, 220)), - style, - ); - } - - for (idx, label) in layout.text_labels.iter().enumerate() { - if hover_text_idx == Some(idx) { - painter.rect_stroke( - text_label_rect(geometry, label).expand(2.0), - 0.0, - Stroke::new(2.0, Color32::from_rgb(255, 220, 120)), - egui::StrokeKind::Middle, - ); - } - let pos = cell_center(geometry, label.cell.0, label.cell.1); - let color = if colorblind_mode { - Color32::BLACK - } else { - Color32::WHITE - }; - painter.text( - pos, - egui::Align2::CENTER_CENTER, - &label.text, - egui::FontId::proportional(label.font_size as f32), - color, - ); - } - - draw_area_marker_group( - painter, - geometry, - &layout.start_markers, - "S", - Color32::from_rgb(60, 220, 200), - colorblind_mode, - hover_marker, - MarkerKind::Start, - ); - draw_area_marker_group( - painter, - geometry, - &layout.end_markers, - "E", - Color32::from_rgb(240, 90, 90), - colorblind_mode, - hover_marker, - MarkerKind::End, - ); - draw_area_marker_group( - painter, - geometry, - &layout.trap_markers, - "T", - Color32::from_rgb(150, 80, 230), // Purple - colorblind_mode, - hover_marker, - MarkerKind::Trap, - ); - draw_area_marker_group( - painter, - geometry, - &layout.monster_markers, - "M", - Color32::from_rgb(200, 50, 50), // Red - colorblind_mode, - hover_marker, - MarkerKind::Monster, - ); - - // Draw stairs and elevators. - for (idx, stair) in layout.stairs.iter().enumerate() { - draw_staircase( - painter, - geometry, - stair, - colorblind_mode, - hover_stair_idx == Some(idx), - ); - } -} - -// Draw a staircase or elevator within a room. -fn draw_staircase( - painter: &egui::Painter, - geometry: &GridGeometry, - stair: &layout::Staircase, - colorblind_mode: bool, - is_hovered: bool, -) { - 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 rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); - - // Fill with a distinctive color for stairs. - let fill = if is_hovered { - Color32::from_rgb(220, 180, 80).gamma_multiply(0.5) - } else if colorblind_mode { - Color32::from_rgb(180, 180, 180).gamma_multiply(0.4) - } else { - Color32::from_rgb(200, 150, 50).gamma_multiply(0.35) - }; - painter.rect_filled(rect, 4.0, fill); - - // Draw border. - let stroke_color = if is_hovered { - Color32::WHITE - } else if colorblind_mode { - Color32::BLACK - } else { - Color32::from_rgb(200, 150, 50) - }; - painter.rect_stroke( - rect, - 4.0, - Stroke::new(if is_hovered { 3.0 } else { 2.0 }, stroke_color), - egui::StrokeKind::Middle, - ); - - // Draw stair lines (horizontal steps). - let steps = (stair.height as f32 * 2.0).round() as usize; - let step_height = (rect.height() / (steps as f32)).max(1.0); - let line_color = if colorblind_mode { - Color32::BLACK - } else { - Color32::from_rgb(150, 100, 30) - }; - 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), - ); - } -} - -// Compute the rectangle for a given grid cell. -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), - ) -} - -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, - ), - ) -} - -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 -} - -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), - ) -} - -fn draw_area_marker( - painter: &egui::Painter, - geometry: &GridGeometry, - marker: &layout::AreaMarker, - label: &str, - base_color: Color32, - colorblind_mode: bool, - hovered: bool, -) { - let rect = marker_rect(marker, geometry); - let fill = base_color.gamma_multiply(if colorblind_mode { 0.22 } else { 0.35 }); - let stroke_color = if hovered { - Color32::from_rgb(255, 220, 120) - } else if colorblind_mode { - Color32::BLACK - } else { - base_color - }; - let text_color = if colorblind_mode { - Color32::BLACK - } else { - Color32::WHITE - }; - let font_size = (geometry.cell_size * marker.size as f32 * 0.5).clamp(14.0, 72.0); - - painter.rect_filled(rect, 4.0, fill); - painter.rect_stroke( - rect, - 4.0, - Stroke::new(if hovered { 3.0 } else { 2.0 }, stroke_color), - egui::StrokeKind::Middle, - ); - painter.text( - rect.center(), - egui::Align2::CENTER_CENTER, - label, - egui::FontId::proportional(font_size), - text_color, - ); -} - -fn draw_area_marker_group( - painter: &egui::Painter, - geometry: &GridGeometry, - markers: &[layout::AreaMarker], - label: &str, - base_color: Color32, - colorblind_mode: bool, - hover_marker: Option, - kind: MarkerKind, -) { - for (idx, marker) in markers.iter().enumerate() { - let hovered = match (kind, hover_marker) { - (MarkerKind::Start, Some(HoverMarker::Start(hover_idx))) => hover_idx == idx, - (MarkerKind::End, Some(HoverMarker::End(hover_idx))) => hover_idx == idx, - (MarkerKind::Trap, Some(HoverMarker::Trap(hover_idx))) => hover_idx == idx, - (MarkerKind::Monster, Some(HoverMarker::Monster(hover_idx))) => hover_idx == idx, - _ => false, - }; - draw_area_marker( - painter, - geometry, - marker, - label, - base_color, - colorblind_mode, - hovered, - ); - } -} - -// Draw a rectangular wall segment between two points. -fn draw_wall_segment( - painter: &egui::Painter, - from: egui::Pos2, - to: egui::Pos2, - width: f32, - color: Color32, -) { - let half = width * 0.5; - if (from.x - to.x).abs() <= f32::EPSILON { - let x = from.x.min(to.x); - let y0 = from.y.min(to.y); - let y1 = from.y.max(to.y); - let rect = egui::Rect::from_min_max( - egui::pos2(x - half, y0 - half), - egui::pos2(x + half, y1 + half), - ); - painter.rect_filled(rect, 0.0, color); - } else { - let y = from.y.min(to.y); - let x0 = from.x.min(to.x); - let x1 = from.x.max(to.x); - let rect = egui::Rect::from_min_max( - egui::pos2(x0 - half, y - half), - egui::pos2(x1 + half, y + half), - ); - painter.rect_filled(rect, 0.0, color); - } -} - -// Normalize an edge tuple to a stable ordering. -fn normalized_edge(a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) { - if a <= b { (a, b) } else { (b, a) } -} - -// Build a set of corridor-adjacent edges from corridor cells. -fn corridor_edges_from_cells( - corridor_cells: &HashSet<(usize, usize)>, -) -> HashSet<((usize, usize), (usize, usize))> { - let mut edges = HashSet::new(); - for &(col, row) in corridor_cells { - let right = (col + 1, row); - let bottom = (col, row + 1); - if corridor_cells.contains(&right) { - edges.insert(normalized_edge((col, row), right)); - } - if corridor_cells.contains(&bottom) { - edges.insert(normalized_edge((col, row), bottom)); - } - } - edges -} - -// Build a set of internal room edges from room rectangles. -fn room_edges_from_rooms(rooms: &[layout::Room]) -> HashSet<((usize, usize), (usize, usize))> { - let mut edges = HashSet::new(); - for room in rooms { - let x_end = room.x + room.width; - let y_end = room.y + room.height; - for x in room.x..x_end { - for y in room.y..y_end { - if x + 1 < x_end { - edges.insert(normalized_edge((x, y), (x + 1, y))); - } - if y + 1 < y_end { - edges.insert(normalized_edge((x, y), (x, y + 1))); - } - } - } - } - edges -} - -// Find the index of the room containing a specific cell. -fn room_index_at_cell(rooms: &[layout::Room], cell: (usize, usize)) -> Option { - rooms.iter().position(|room| { - cell.0 >= room.x - && cell.0 < room.x + room.width - && cell.1 >= room.y - && cell.1 < room.y + room.height - }) -} - -// Check whether two rectangles overlap by area. -fn rects_overlap( - ax: usize, - ay: usize, - aw: usize, - ah: usize, - bx: usize, - by: usize, - bw: usize, - bh: usize, -) -> bool { - let a_right = ax + aw; - let a_bottom = ay + ah; - let b_right = bx + bw; - let b_bottom = by + bh; - ax < b_right && a_right > bx && ay < b_bottom && a_bottom > by -} - -// Check whether two rooms overlap by area. -fn rooms_overlap(a: &layout::Room, b: &layout::Room) -> bool { - rects_overlap(a.x, a.y, a.width, a.height, b.x, b.y, b.width, b.height) -} - -fn manual_door_edge_allowed( - a_room: Option, - b_room: Option, - a_in_corridor: bool, - b_in_corridor: bool, -) -> bool { - if matches!((a_room, b_room), (Some(a), Some(b)) if a == b) { - return false; - } - - let a_is_feature = a_room.is_some() || a_in_corridor; - let b_is_feature = b_room.is_some() || b_in_corridor; - a_is_feature || b_is_feature -} - -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 -} - -// Draw a single resize handle glyph at a corner. -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, - ); - } - } -} - -// Expand a door into all grid edges it spans based on width. -fn door_edges_for( - door: &layout::Door, - cols: usize, - rows: usize, -) -> Vec<((usize, usize), (usize, usize))> { - let mut edges = Vec::new(); - if door.from.0.abs_diff(door.to.0) + door.from.1.abs_diff(door.to.1) != 1 { - return edges; - } - - let width = door_render_width(door).max(1) as isize; - let min_offset = -((width - 1) / 2); - let max_offset = width / 2; - - if door.from.0 != door.to.0 { - let y = door.from.1 as isize; - for dy in min_offset..=max_offset { - let ny = y + dy; - if ny < 0 || ny >= rows as isize { - continue; - } - let a = (door.from.0, ny as usize); - let b = (door.to.0, ny as usize); - edges.push(normalized_edge(a, b)); - } - } else { - let x = door.from.0 as isize; - for dx in min_offset..=max_offset { - let nx = x + dx; - if nx < 0 || nx >= cols as isize { - continue; - } - let a = (nx as usize, door.from.1); - let b = (nx as usize, door.to.1); - edges.push(normalized_edge(a, b)); - } - } - - edges -} - -// Determine the rendered door width in cells. -fn door_render_width(door: &layout::Door) -> usize { - if door.span_width { - door.width.max(1) - } else { - 1 - } -} - -fn window_render_width(window: &layout::Window) -> usize { - if window.span_width { - window.width.max(1) - } else { - 1 - } -} - -// Convert a pointer position to grid coordinates if inside the grid. -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)) -} - -// Recompute a path that must pass through a target cell. -fn reroute_path_through_cell( - original_path: &[(usize, usize)], - original_cell: (usize, usize), - target_cell: (usize, usize), - cols: usize, - rows: usize, - blocked_room_cells_set: &HashSet<(usize, usize)>, -) -> Option> { - let (&start, &end) = (original_path.first()?, original_path.last()?); - if start == end { - return None; - } - - let mut blocked = blocked_room_cells_set.clone(); - blocked.insert(original_cell); - blocked.remove(&start); - blocked.remove(&end); - blocked.remove(&target_cell); - - let first_leg = shortest_path_cells(start, target_cell, cols, rows, &blocked)?; - - let mut blocked_second = blocked.clone(); - for &cell in first_leg - .iter() - .skip(1) - .take(first_leg.len().saturating_sub(2)) - { - if cell != target_cell && cell != end { - blocked_second.insert(cell); - } - } - - let second_leg = shortest_path_cells(target_cell, end, cols, rows, &blocked_second) - .or_else(|| shortest_path_cells(target_cell, end, cols, rows, &blocked))?; - - let mut full_path = first_leg; - for cell in second_leg.into_iter().skip(1) { - if full_path.last().copied() != Some(cell) { - full_path.push(cell); - } - } - - simplify_path_loops(&mut full_path); - - if full_path.len() < 2 || !full_path.contains(&target_cell) { - return None; - } - - if original_cell != start && original_cell != end && full_path.contains(&original_cell) { - return None; - } - - Some(full_path) -} - -// Remove loops in a path by truncating back to repeated cells. -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; -} - -// Convert UI door settings into layout door settings. -fn door_settings_from_ui(settings: &UiSettings) -> DoorSettings { - DoorSettings { - frequency_percent: settings.door_frequency_percent, - room_hallway_percent: settings.room_hallway_door_percent, - locked_percent: settings.locked_door_percent, - secret_percent: settings.secret_door_percent, - allow_middle_corridor_doors: settings.allow_middle_corridor_doors, - } -} - -fn window_settings_from_ui(settings: &UiSettings) -> WindowSettings { - WindowSettings { - enabled: settings.windows_enabled, - min_width: settings.min_window_width, - max_width: settings.max_window_width, - frequency_percent: settings.window_frequency_percent, - room_hallway_percent: settings.room_hallway_window_percent, - allow_internal_windows: settings.allow_internal_windows, - } -} - -// Draw a door line segment with a selected style. -fn draw_door_line( - painter: &egui::Painter, - geometry: &GridGeometry, - a: (usize, usize), - b: (usize, usize), - width_cells: usize, - stroke: Stroke, - style: DoorLineStyle, -) { - if a.0.abs_diff(b.0) + a.1.abs_diff(b.1) != 1 { - return; - } - - let width_cells = width_cells.max(1); - let min_offset = -((width_cells as isize - 1) / 2); - let max_offset = width_cells as isize / 2; - - if a.0 != b.0 { - let x = geometry.rect.left() + (a.0.max(b.0) as f32) * geometry.cell_size; - let row = a.1 as isize; - let y0_cell = (row + min_offset).clamp(0, geometry.rows.saturating_sub(1) as isize); - let y1_cell = (row + max_offset).clamp(0, geometry.rows.saturating_sub(1) as isize); - let y0 = geometry.rect.top() + y0_cell as f32 * geometry.cell_size; - let y1 = geometry.rect.top() + (y1_cell as f32 + 1.0) * geometry.cell_size; - draw_styled_line(painter, egui::pos2(x, y0), egui::pos2(x, y1), stroke, style); - } else { - let y = geometry.rect.top() + (a.1.max(b.1) as f32) * geometry.cell_size; - let col = a.0 as isize; - let x0_cell = (col + min_offset).clamp(0, geometry.cols.saturating_sub(1) as isize); - let x1_cell = (col + max_offset).clamp(0, geometry.cols.saturating_sub(1) as isize); - let x0 = geometry.rect.left() + x0_cell as f32 * geometry.cell_size; - let x1 = geometry.rect.left() + (x1_cell as f32 + 1.0) * geometry.cell_size; - draw_styled_line(painter, egui::pos2(x0, y), egui::pos2(x1, y), stroke, style); - } -} - -fn draw_window_line( - painter: &egui::Painter, - geometry: &GridGeometry, - window: &layout::Window, - stroke: Stroke, - style: DoorLineStyle, -) { - let width_cells = window_render_width(window).max(1) as isize; - let min_offset = -((width_cells - 1) / 2); - let max_offset = width_cells / 2; - - match window.side { - layout::WindowSide::Left => { - let x = geometry.rect.left() + window.cell.0 as f32 * geometry.cell_size; - let row = window.cell.1 as isize; - let y0_cell = (row + min_offset).clamp(0, geometry.rows.saturating_sub(1) as isize); - let y1_cell = (row + max_offset).clamp(0, geometry.rows.saturating_sub(1) as isize); - let y0 = geometry.rect.top() + y0_cell as f32 * geometry.cell_size; - let y1 = geometry.rect.top() + (y1_cell as f32 + 1.0) * geometry.cell_size; - draw_styled_line(painter, egui::pos2(x, y0), egui::pos2(x, y1), stroke, style); - } - layout::WindowSide::Right => { - let x = geometry.rect.left() + (window.cell.0 as f32 + 1.0) * geometry.cell_size; - let row = window.cell.1 as isize; - let y0_cell = (row + min_offset).clamp(0, geometry.rows.saturating_sub(1) as isize); - let y1_cell = (row + max_offset).clamp(0, geometry.rows.saturating_sub(1) as isize); - let y0 = geometry.rect.top() + y0_cell as f32 * geometry.cell_size; - let y1 = geometry.rect.top() + (y1_cell as f32 + 1.0) * geometry.cell_size; - draw_styled_line(painter, egui::pos2(x, y0), egui::pos2(x, y1), stroke, style); - } - layout::WindowSide::Top => { - let y = geometry.rect.top() + window.cell.1 as f32 * geometry.cell_size; - let col = window.cell.0 as isize; - let x0_cell = (col + min_offset).clamp(0, geometry.cols.saturating_sub(1) as isize); - let x1_cell = (col + max_offset).clamp(0, geometry.cols.saturating_sub(1) as isize); - let x0 = geometry.rect.left() + x0_cell as f32 * geometry.cell_size; - let x1 = geometry.rect.left() + (x1_cell as f32 + 1.0) * geometry.cell_size; - draw_styled_line(painter, egui::pos2(x0, y), egui::pos2(x1, y), stroke, style); - } - layout::WindowSide::Bottom => { - let y = geometry.rect.top() + (window.cell.1 as f32 + 1.0) * geometry.cell_size; - let col = window.cell.0 as isize; - let x0_cell = (col + min_offset).clamp(0, geometry.cols.saturating_sub(1) as isize); - let x1_cell = (col + max_offset).clamp(0, geometry.cols.saturating_sub(1) as isize); - let x0 = geometry.rect.left() + x0_cell as f32 * geometry.cell_size; - let x1 = geometry.rect.left() + (x1_cell as f32 + 1.0) * geometry.cell_size; - draw_styled_line(painter, egui::pos2(x0, y), egui::pos2(x1, y), stroke, style); - } - } -} - -#[derive(Debug, Clone, Copy)] -enum DoorLineStyle { - Solid, - LongDash, - ShortDash, - Dotted, - DashDot, - DashDotDot, -} - -// Draw a line with solid, dashed, or dotted styling. -fn draw_styled_line( - painter: &egui::Painter, - from: egui::Pos2, - to: egui::Pos2, - stroke: Stroke, - style: DoorLineStyle, -) { - match style { - DoorLineStyle::Solid => { - painter.line_segment([from, to], stroke); - } - DoorLineStyle::LongDash => draw_dashed_line(painter, from, to, stroke, 14.0, 7.0), - DoorLineStyle::ShortDash => draw_dashed_line(painter, from, to, stroke, 6.0, 4.0), - DoorLineStyle::Dotted => draw_dotted_line(painter, from, to, stroke), - DoorLineStyle::DashDot => draw_dash_dot_line(painter, from, to, stroke), - DoorLineStyle::DashDotDot => draw_dash_dot_dot_line(painter, from, to, stroke), - } -} - -// Draw a dashed line between two points. -fn draw_dashed_line( - painter: &egui::Painter, - from: egui::Pos2, - to: egui::Pos2, - stroke: Stroke, - dash_len: f32, - gap_len: f32, -) { - let dx = to.x - from.x; - let dy = to.y - from.y; - let len = (dx * dx + dy * dy).sqrt(); - if len <= 0.0 { - return; - } - let ux = dx / len; - let uy = dy / len; - - let mut dist = 0.0_f32; - while dist < len { - let seg_start = dist; - let seg_end = (dist + dash_len).min(len); - let p0 = egui::pos2(from.x + ux * seg_start, from.y + uy * seg_start); - let p1 = egui::pos2(from.x + ux * seg_end, from.y + uy * seg_end); - painter.line_segment([p0, p1], stroke); - dist += dash_len + gap_len; - } -} - -// Draw a dotted line between two points. -fn draw_dotted_line(painter: &egui::Painter, from: egui::Pos2, to: egui::Pos2, stroke: Stroke) { - let dx = to.x - from.x; - let dy = to.y - from.y; - let len = (dx * dx + dy * dy).sqrt(); - if len <= 0.0 { - return; - } - let ux = dx / len; - let uy = dy / len; - let step = 5.0_f32; - let radius = (stroke.width * 0.35).max(1.0); - - let mut dist = 0.0_f32; - while dist <= len { - let p = egui::pos2(from.x + ux * dist, from.y + uy * dist); - painter.circle_filled(p, radius, stroke.color); - dist += step; - } -} - -fn draw_dash_dot_line(painter: &egui::Painter, from: egui::Pos2, to: egui::Pos2, stroke: Stroke) { - let dx = to.x - from.x; - let dy = to.y - from.y; - let len = (dx * dx + dy * dy).sqrt(); - if len <= 0.0 { - return; - } - - let ux = dx / len; - let uy = dy / len; - let mut dist = 0.0_f32; - while dist < len { - let dash_end = (dist + 7.0).min(len); - let p0 = egui::pos2(from.x + ux * dist, from.y + uy * dist); - let p1 = egui::pos2(from.x + ux * dash_end, from.y + uy * dash_end); - painter.line_segment([p0, p1], stroke); - dist = dash_end + 3.0; - if dist >= len { - break; - } - let dot = egui::pos2(from.x + ux * dist, from.y + uy * dist); - painter.circle_filled(dot, (stroke.width * 0.35).max(1.0), stroke.color); - dist += 4.0; - } -} - -fn draw_dash_dot_dot_line( - painter: &egui::Painter, - from: egui::Pos2, - to: egui::Pos2, - stroke: Stroke, -) { - let dx = to.x - from.x; - let dy = to.y - from.y; - let len = (dx * dx + dy * dy).sqrt(); - if len <= 0.0 { - return; - } - - let ux = dx / len; - let uy = dy / len; - let radius = (stroke.width * 0.35).max(1.0); - let mut dist = 0.0_f32; - while dist < len { - let dash_end = (dist + 7.0).min(len); - let p0 = egui::pos2(from.x + ux * dist, from.y + uy * dist); - let p1 = egui::pos2(from.x + ux * dash_end, from.y + uy * dash_end); - painter.line_segment([p0, p1], stroke); - dist = dash_end + 3.0; - if dist >= len { - break; - } - let dot1 = egui::pos2(from.x + ux * dist, from.y + uy * dist); - painter.circle_filled(dot1, radius, stroke.color); - dist += 3.0; - if dist >= len { - break; - } - let dot2 = egui::pos2(from.x + ux * dist, from.y + uy * dist); - painter.circle_filled(dot2, radius, stroke.color); - dist += 4.0; - } -} - -// Compute the center position of a grid cell. -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, - ) -} - -fn text_label_rect(geometry: &GridGeometry, label: &layout::TextLabel) -> egui::Rect { - let center = cell_center(geometry, label.cell.0, label.cell.1); - let font_size = label.font_size as f32; - let width = - (label.text.chars().count().max(1) as f32 * font_size * 0.6).max(geometry.cell_size * 0.5); - let height = font_size.max(geometry.cell_size * 0.35); - egui::Rect::from_center_size(center, egui::vec2(width, height)) -} - -// Draw a crosshatch pattern inside a room for colorblind mode. -fn draw_room_crosshatch( - painter: &egui::Painter, - geometry: &GridGeometry, - room: &layout::Room, - stroke: Stroke, -) { - for x in room.x..(room.x + room.width) { - for y in room.y..(room.y + room.height) { - let rect = cell_rect(geometry, x, y).shrink(2.0); - painter.line_segment([rect.left_top(), rect.right_bottom()], stroke); - painter.line_segment([rect.right_top(), rect.left_bottom()], stroke); - } - } -} diff --git a/src/rendering.rs b/src/rendering.rs new file mode 100644 index 0000000..218221e --- /dev/null +++ b/src/rendering.rs @@ -0,0 +1,770 @@ +use crate::interact::{ + GridGeometry, HoverMarker, MarkerKind, cell_center, cell_rect, door_edges_for, + door_render_width, marker_rect, normalized_edge, window_render_width, +}; +use crate::layout::{DungeonLayout, corridor_cells}; +use eframe::egui; +use egui::{Color32, Stroke}; +use std::collections::HashSet; + +pub fn draw_layout( + painter: &egui::Painter, + geometry: &GridGeometry, + layout: &DungeonLayout, + colorblind_mode: bool, + hover_text_idx: Option, + hover_marker: Option, + hover_stair_idx: Option, +) { + let wall_width_px = (geometry.cell_size / 2.5).max(1.0); + let door_width_px = (geometry.cell_size / 5.0).max(1.0); + + let room_fill = Color32::from_rgb(70, 120, 160); + let corridor_fill = Color32::from_rgb(210, 190, 120); + let wall_color = Color32::BLACK; + let corridor_cells_set = corridor_cells(layout, geometry.cols, geometry.rows); + let corridor_edges_set = corridor_edges_from_cells(&corridor_cells_set); + let mut room_cells_set = HashSet::new(); + let room_edges_set = room_edges_from_rooms(&layout.rooms); + let mut door_edges_set = HashSet::new(); + + for door in &layout.doors { + for edge in door_edges_for(door, geometry.cols, geometry.rows) { + door_edges_set.insert(edge); + } + } + + for &(col, row) in &corridor_cells_set { + painter.rect_filled(cell_rect(geometry, col, row), 0.0, corridor_fill); + if colorblind_mode { + painter.circle_filled( + cell_center(geometry, col, row), + geometry.cell_size * 0.14, + Color32::BLACK, + ); + } + } + + for &(col, row) in &corridor_cells_set { + let rect = cell_rect(geometry, col, row); + let right = (col + 1, row); + let bottom = (col, row + 1); + let left = col.checked_sub(1).map(|x| (x, row)); + let top = row.checked_sub(1).map(|y| (col, y)); + + if col == 0 + || (!corridor_cells_set.contains(&(col - 1, row)) + && left + .map(|n| !door_edges_set.contains(&normalized_edge((col, row), n))) + .unwrap_or(true)) + { + draw_wall_segment( + painter, + egui::pos2(rect.left(), rect.top()), + egui::pos2(rect.left(), rect.bottom()), + wall_width_px, + wall_color, + ); + } + if (!corridor_cells_set.contains(&right) + || !corridor_edges_set.contains(&normalized_edge((col, row), right))) + && !door_edges_set.contains(&normalized_edge((col, row), right)) + { + draw_wall_segment( + painter, + egui::pos2(rect.right(), rect.top()), + egui::pos2(rect.right(), rect.bottom()), + wall_width_px, + wall_color, + ); + } + if row == 0 + || (!corridor_cells_set.contains(&(col, row - 1)) + && top + .map(|n| !door_edges_set.contains(&normalized_edge((col, row), n))) + .unwrap_or(true)) + { + draw_wall_segment( + painter, + egui::pos2(rect.left(), rect.top()), + egui::pos2(rect.right(), rect.top()), + wall_width_px, + wall_color, + ); + } + if (!corridor_cells_set.contains(&bottom) + || !corridor_edges_set.contains(&normalized_edge((col, row), bottom))) + && !door_edges_set.contains(&normalized_edge((col, row), bottom)) + { + draw_wall_segment( + painter, + egui::pos2(rect.left(), rect.bottom()), + egui::pos2(rect.right(), rect.bottom()), + wall_width_px, + wall_color, + ); + } + } + + for room in &layout.rooms { + for x in room.x..(room.x + room.width) { + for y in room.y..(room.y + room.height) { + room_cells_set.insert((x, y)); + } + } + + 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 room_rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); + + painter.rect_filled(room_rect, 0.0, room_fill); + if colorblind_mode { + draw_room_crosshatch(painter, geometry, room, Stroke::new(1.5, Color32::BLACK)); + } + } + + for &(col, row) in &room_cells_set { + let rect = cell_rect(geometry, col, row); + let right = (col + 1, row); + let bottom = (col, row + 1); + let left = col.checked_sub(1).map(|x| (x, row)); + let top = row.checked_sub(1).map(|y| (col, y)); + + let left_edge = col == 0 + || (!room_cells_set.contains(&(col - 1, row)) + || left + .map(|n| !room_edges_set.contains(&normalized_edge((col, row), n))) + .unwrap_or(true)) + && left + .map(|n| !door_edges_set.contains(&normalized_edge((col, row), n))) + .unwrap_or(true); + if left_edge { + draw_wall_segment( + painter, + egui::pos2(rect.left(), rect.top()), + egui::pos2(rect.left(), rect.bottom()), + wall_width_px, + wall_color, + ); + } + if (!room_cells_set.contains(&right) + || !room_edges_set.contains(&normalized_edge((col, row), right))) + && !door_edges_set.contains(&normalized_edge((col, row), right)) + { + draw_wall_segment( + painter, + egui::pos2(rect.right(), rect.top()), + egui::pos2(rect.right(), rect.bottom()), + wall_width_px, + wall_color, + ); + } + let top_edge = row == 0 + || (!room_cells_set.contains(&(col, row - 1)) + || top + .map(|n| !room_edges_set.contains(&normalized_edge((col, row), n))) + .unwrap_or(true)) + && top + .map(|n| !door_edges_set.contains(&normalized_edge((col, row), n))) + .unwrap_or(true); + if top_edge { + draw_wall_segment( + painter, + egui::pos2(rect.left(), rect.top()), + egui::pos2(rect.right(), rect.top()), + wall_width_px, + wall_color, + ); + } + if (!room_cells_set.contains(&bottom) + || !room_edges_set.contains(&normalized_edge((col, row), bottom))) + && !door_edges_set.contains(&normalized_edge((col, row), bottom)) + { + draw_wall_segment( + painter, + egui::pos2(rect.left(), rect.bottom()), + egui::pos2(rect.right(), rect.bottom()), + wall_width_px, + wall_color, + ); + } + } + + for door in &layout.doors { + let color = if colorblind_mode { + Color32::BLACK + } else if door.secret { + Color32::from_rgb(170, 80, 170) + } else if door.archway { + Color32::from_rgb(230, 140, 60) + } else if door.locked { + Color32::from_rgb(220, 70, 70) + } else { + Color32::from_rgb(80, 200, 120) + }; + let style = if colorblind_mode { + if door.secret { + DoorLineStyle::DashDotDot + } else if door.archway { + DoorLineStyle::Dotted + } else if door.locked { + DoorLineStyle::ShortDash + } else { + DoorLineStyle::LongDash + } + } else { + DoorLineStyle::Solid + }; + draw_door_line( + painter, + geometry, + door.from, + door.to, + door_render_width(door), + Stroke::new(door_width_px, color), + style, + ); + } + + for window in &layout.windows { + let style = if colorblind_mode { + DoorLineStyle::DashDot + } else { + DoorLineStyle::Solid + }; + draw_window_line( + painter, + geometry, + window, + Stroke::new(door_width_px, Color32::from_rgb(70, 130, 220)), + style, + ); + } + + for (idx, label) in layout.text_labels.iter().enumerate() { + if hover_text_idx == Some(idx) { + painter.rect_stroke( + text_label_rect(geometry, label).expand(2.0), + 0.0, + Stroke::new(2.0, Color32::from_rgb(255, 220, 120)), + egui::StrokeKind::Middle, + ); + } + let pos = cell_center(geometry, label.cell.0, label.cell.1); + let color = if colorblind_mode { + Color32::BLACK + } else { + Color32::WHITE + }; + painter.text( + pos, + egui::Align2::CENTER_CENTER, + &label.text, + egui::FontId::proportional(label.font_size as f32), + color, + ); + } + + draw_area_marker_group( + painter, + geometry, + &layout.start_markers, + "S", + Color32::from_rgb(60, 220, 200), + colorblind_mode, + hover_marker, + MarkerKind::Start, + ); + draw_area_marker_group( + painter, + geometry, + &layout.end_markers, + "E", + Color32::from_rgb(240, 90, 90), + colorblind_mode, + hover_marker, + MarkerKind::End, + ); + draw_area_marker_group( + painter, + geometry, + &layout.trap_markers, + "T", + Color32::from_rgb(150, 80, 230), + colorblind_mode, + hover_marker, + MarkerKind::Trap, + ); + draw_area_marker_group( + painter, + geometry, + &layout.monster_markers, + "M", + Color32::from_rgb(200, 50, 50), + colorblind_mode, + hover_marker, + MarkerKind::Monster, + ); + + for (idx, stair) in layout.stairs.iter().enumerate() { + draw_staircase( + painter, + geometry, + stair, + colorblind_mode, + hover_stair_idx == Some(idx), + ); + } +} + +pub fn draw_staircase( + painter: &egui::Painter, + geometry: &GridGeometry, + stair: &crate::layout::Staircase, + colorblind_mode: bool, + is_hovered: bool, +) { + 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 rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom)); + + let fill = if is_hovered { + Color32::from_rgb(220, 180, 80).gamma_multiply(0.5) + } else if colorblind_mode { + Color32::from_rgb(180, 180, 180).gamma_multiply(0.4) + } else { + Color32::from_rgb(200, 150, 50).gamma_multiply(0.35) + }; + painter.rect_filled(rect, 4.0, fill); + + let stroke_color = if is_hovered { + Color32::WHITE + } else if colorblind_mode { + Color32::BLACK + } else { + Color32::from_rgb(200, 150, 50) + }; + painter.rect_stroke( + rect, + 4.0, + Stroke::new(if is_hovered { 3.0 } else { 2.0 }, stroke_color), + egui::StrokeKind::Middle, + ); + + let steps = (stair.height as f32 * 2.0).round() as usize; + let step_height = (rect.height() / (steps as f32)).max(1.0); + let line_color = if colorblind_mode { + Color32::BLACK + } else { + Color32::from_rgb(150, 100, 30) + }; + 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), + ); + } +} + +pub fn text_label_rect(geometry: &GridGeometry, label: &crate::layout::TextLabel) -> egui::Rect { + let center = cell_center(geometry, label.cell.0, label.cell.1); + let font_size = label.font_size as f32; + let width = + (label.text.chars().count().max(1) as f32 * font_size * 0.6).max(geometry.cell_size * 0.5); + let height = font_size.max(geometry.cell_size * 0.35); + egui::Rect::from_center_size(center, egui::vec2(width, height)) +} + +pub fn draw_area_marker( + painter: &egui::Painter, + geometry: &GridGeometry, + marker: &crate::layout::AreaMarker, + label: &str, + base_color: Color32, + colorblind_mode: bool, + hovered: bool, +) { + let rect = marker_rect(marker, geometry); + let fill = base_color.gamma_multiply(if colorblind_mode { 0.22 } else { 0.35 }); + let stroke_color = if hovered { + Color32::from_rgb(255, 220, 120) + } else if colorblind_mode { + Color32::BLACK + } else { + base_color + }; + let text_color = if colorblind_mode { + Color32::BLACK + } else { + Color32::WHITE + }; + let font_size = (geometry.cell_size * marker.size as f32 * 0.5).clamp(14.0, 72.0); + + painter.rect_filled(rect, 4.0, fill); + painter.rect_stroke( + rect, + 4.0, + Stroke::new(if hovered { 3.0 } else { 2.0 }, stroke_color), + egui::StrokeKind::Middle, + ); + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(font_size), + text_color, + ); +} + +pub fn draw_area_marker_group( + painter: &egui::Painter, + geometry: &GridGeometry, + markers: &[crate::layout::AreaMarker], + label: &str, + base_color: Color32, + colorblind_mode: bool, + hover_marker: Option, + kind: MarkerKind, +) { + for (idx, marker) in markers.iter().enumerate() { + let hovered = match (kind, hover_marker) { + (MarkerKind::Start, Some(HoverMarker::Start(hover_idx))) => hover_idx == idx, + (MarkerKind::End, Some(HoverMarker::End(hover_idx))) => hover_idx == idx, + (MarkerKind::Trap, Some(HoverMarker::Trap(hover_idx))) => hover_idx == idx, + (MarkerKind::Monster, Some(HoverMarker::Monster(hover_idx))) => hover_idx == idx, + _ => false, + }; + draw_area_marker( + painter, + geometry, + marker, + label, + base_color, + colorblind_mode, + hovered, + ); + } +} + +pub fn draw_wall_segment( + painter: &egui::Painter, + from: egui::Pos2, + to: egui::Pos2, + width: f32, + color: Color32, +) { + let half = width * 0.5; + if (from.x - to.x).abs() <= f32::EPSILON { + let x = from.x.min(to.x); + let y0 = from.y.min(to.y); + let y1 = from.y.max(to.y); + let rect = egui::Rect::from_min_max( + egui::pos2(x - half, y0 - half), + egui::pos2(x + half, y1 + half), + ); + painter.rect_filled(rect, 0.0, color); + } else { + let y = from.y.min(to.y); + let x0 = from.x.min(to.x); + let x1 = from.x.max(to.x); + let rect = egui::Rect::from_min_max( + egui::pos2(x0 - half, y - half), + egui::pos2(x1 + half, y + half), + ); + painter.rect_filled(rect, 0.0, color); + } +} + +pub fn corridor_edges_from_cells( + corridor_cells: &HashSet<(usize, usize)>, +) -> HashSet<((usize, usize), (usize, usize))> { + let mut edges = HashSet::new(); + for &(col, row) in corridor_cells { + let right = (col + 1, row); + let bottom = (col, row + 1); + if corridor_cells.contains(&right) { + edges.insert(normalized_edge((col, row), right)); + } + if corridor_cells.contains(&bottom) { + edges.insert(normalized_edge((col, row), bottom)); + } + } + edges +} + +pub fn room_edges_from_rooms( + rooms: &[crate::layout::Room], +) -> HashSet<((usize, usize), (usize, usize))> { + let mut edges = HashSet::new(); + for room in rooms { + let x_end = room.x + room.width; + let y_end = room.y + room.height; + for x in room.x..x_end { + for y in room.y..y_end { + if x + 1 < x_end { + edges.insert(normalized_edge((x, y), (x + 1, y))); + } + if y + 1 < y_end { + edges.insert(normalized_edge((x, y), (x, y + 1))); + } + } + } + } + edges +} + +#[derive(Debug, Clone, Copy)] +pub enum DoorLineStyle { + Solid, + LongDash, + ShortDash, + Dotted, + DashDot, + DashDotDot, +} + +pub fn draw_door_line( + painter: &egui::Painter, + geometry: &GridGeometry, + a: (usize, usize), + b: (usize, usize), + width_cells: usize, + stroke: Stroke, + style: DoorLineStyle, +) { + if a.0.abs_diff(b.0) + a.1.abs_diff(b.1) != 1 { + return; + } + + let width_cells = width_cells.max(1); + let min_offset = -((width_cells as isize - 1) / 2); + let max_offset = width_cells as isize / 2; + + if a.0 != b.0 { + let x = geometry.rect.left() + (a.0.max(b.0) as f32) * geometry.cell_size; + let row = a.1 as isize; + let y0_cell = (row + min_offset).clamp(0, geometry.rows.saturating_sub(1) as isize); + let y1_cell = (row + max_offset).clamp(0, geometry.rows.saturating_sub(1) as isize); + let y0 = geometry.rect.top() + y0_cell as f32 * geometry.cell_size; + let y1 = geometry.rect.top() + (y1_cell as f32 + 1.0) * geometry.cell_size; + draw_styled_line(painter, egui::pos2(x, y0), egui::pos2(x, y1), stroke, style); + } else { + let y = geometry.rect.top() + (a.1.max(b.1) as f32) * geometry.cell_size; + let col = a.0 as isize; + let x0_cell = (col + min_offset).clamp(0, geometry.cols.saturating_sub(1) as isize); + let x1_cell = (col + max_offset).clamp(0, geometry.cols.saturating_sub(1) as isize); + let x0 = geometry.rect.left() + x0_cell as f32 * geometry.cell_size; + let x1 = geometry.rect.left() + (x1_cell as f32 + 1.0) * geometry.cell_size; + draw_styled_line(painter, egui::pos2(x0, y), egui::pos2(x1, y), stroke, style); + } +} + +pub fn draw_window_line( + painter: &egui::Painter, + geometry: &GridGeometry, + window: &crate::layout::Window, + stroke: Stroke, + style: DoorLineStyle, +) { + let width_cells = window_render_width(window).max(1) as isize; + let min_offset = -((width_cells - 1) / 2); + let max_offset = width_cells / 2; + + match window.side { + crate::layout::WindowSide::Left => { + let x = geometry.rect.left() + window.cell.0 as f32 * geometry.cell_size; + let row = window.cell.1 as isize; + let y0_cell = (row + min_offset).clamp(0, geometry.rows.saturating_sub(1) as isize); + let y1_cell = (row + max_offset).clamp(0, geometry.rows.saturating_sub(1) as isize); + let y0 = geometry.rect.top() + y0_cell as f32 * geometry.cell_size; + let y1 = geometry.rect.top() + (y1_cell as f32 + 1.0) * geometry.cell_size; + draw_styled_line(painter, egui::pos2(x, y0), egui::pos2(x, y1), stroke, style); + } + crate::layout::WindowSide::Right => { + let x = geometry.rect.left() + (window.cell.0 as f32 + 1.0) * geometry.cell_size; + let row = window.cell.1 as isize; + let y0_cell = (row + min_offset).clamp(0, geometry.rows.saturating_sub(1) as isize); + let y1_cell = (row + max_offset).clamp(0, geometry.rows.saturating_sub(1) as isize); + let y0 = geometry.rect.top() + y0_cell as f32 * geometry.cell_size; + let y1 = geometry.rect.top() + (y1_cell as f32 + 1.0) * geometry.cell_size; + draw_styled_line(painter, egui::pos2(x, y0), egui::pos2(x, y1), stroke, style); + } + crate::layout::WindowSide::Top => { + let y = geometry.rect.top() + window.cell.1 as f32 * geometry.cell_size; + let col = window.cell.0 as isize; + let x0_cell = (col + min_offset).clamp(0, geometry.cols.saturating_sub(1) as isize); + let x1_cell = (col + max_offset).clamp(0, geometry.cols.saturating_sub(1) as isize); + let x0 = geometry.rect.left() + x0_cell as f32 * geometry.cell_size; + let x1 = geometry.rect.left() + (x1_cell as f32 + 1.0) * geometry.cell_size; + draw_styled_line(painter, egui::pos2(x0, y), egui::pos2(x1, y), stroke, style); + } + crate::layout::WindowSide::Bottom => { + let y = geometry.rect.top() + (window.cell.1 as f32 + 1.0) * geometry.cell_size; + let col = window.cell.0 as isize; + let x0_cell = (col + min_offset).clamp(0, geometry.cols.saturating_sub(1) as isize); + let x1_cell = (col + max_offset).clamp(0, geometry.cols.saturating_sub(1) as isize); + let x0 = geometry.rect.left() + x0_cell as f32 * geometry.cell_size; + let x1 = geometry.rect.left() + (x1_cell as f32 + 1.0) * geometry.cell_size; + draw_styled_line(painter, egui::pos2(x0, y), egui::pos2(x1, y), stroke, style); + } + } +} + +pub fn draw_styled_line( + painter: &egui::Painter, + from: egui::Pos2, + to: egui::Pos2, + stroke: Stroke, + style: DoorLineStyle, +) { + match style { + DoorLineStyle::Solid => { + painter.line_segment([from, to], stroke); + } + DoorLineStyle::LongDash => draw_dashed_line(painter, from, to, stroke, 14.0, 7.0), + DoorLineStyle::ShortDash => draw_dashed_line(painter, from, to, stroke, 6.0, 4.0), + DoorLineStyle::Dotted => draw_dotted_line(painter, from, to, stroke), + DoorLineStyle::DashDot => draw_dash_dot_line(painter, from, to, stroke), + DoorLineStyle::DashDotDot => draw_dash_dot_dot_line(painter, from, to, stroke), + } +} + +pub fn draw_dashed_line( + painter: &egui::Painter, + from: egui::Pos2, + to: egui::Pos2, + stroke: Stroke, + dash_len: f32, + gap_len: f32, +) { + let dx = to.x - from.x; + let dy = to.y - from.y; + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + return; + } + let ux = dx / len; + let uy = dy / len; + + let mut dist = 0.0_f32; + while dist < len { + let seg_start = dist; + let seg_end = (dist + dash_len).min(len); + let p0 = egui::pos2(from.x + ux * seg_start, from.y + uy * seg_start); + let p1 = egui::pos2(from.x + ux * seg_end, from.y + uy * seg_end); + painter.line_segment([p0, p1], stroke); + dist += dash_len + gap_len; + } +} + +pub fn draw_dotted_line(painter: &egui::Painter, from: egui::Pos2, to: egui::Pos2, stroke: Stroke) { + let dx = to.x - from.x; + let dy = to.y - from.y; + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + return; + } + let ux = dx / len; + let uy = dy / len; + let step = 5.0_f32; + let radius = (stroke.width * 0.35).max(1.0); + + let mut dist = 0.0_f32; + while dist <= len { + let p = egui::pos2(from.x + ux * dist, from.y + uy * dist); + painter.circle_filled(p, radius, stroke.color); + dist += step; + } +} + +pub fn draw_dash_dot_line( + painter: &egui::Painter, + from: egui::Pos2, + to: egui::Pos2, + stroke: Stroke, +) { + let dx = to.x - from.x; + let dy = to.y - from.y; + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + return; + } + + let ux = dx / len; + let uy = dy / len; + let mut dist = 0.0_f32; + while dist < len { + let dash_end = (dist + 7.0).min(len); + let p0 = egui::pos2(from.x + ux * dist, from.y + uy * dist); + let p1 = egui::pos2(from.x + ux * dash_end, from.y + uy * dash_end); + painter.line_segment([p0, p1], stroke); + dist = dash_end + 3.0; + if dist >= len { + break; + } + let dot = egui::pos2(from.x + ux * dist, from.y + uy * dist); + painter.circle_filled(dot, (stroke.width * 0.35).max(1.0), stroke.color); + dist += 4.0; + } +} + +pub fn draw_dash_dot_dot_line( + painter: &egui::Painter, + from: egui::Pos2, + to: egui::Pos2, + stroke: Stroke, +) { + let dx = to.x - from.x; + let dy = to.y - from.y; + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + return; + } + + let ux = dx / len; + let uy = dy / len; + let radius = (stroke.width * 0.35).max(1.0); + let mut dist = 0.0_f32; + while dist < len { + let dash_end = (dist + 7.0).min(len); + let p0 = egui::pos2(from.x + ux * dist, from.y + uy * dist); + let p1 = egui::pos2(from.x + ux * dash_end, from.y + uy * dash_end); + painter.line_segment([p0, p1], stroke); + dist = dash_end + 3.0; + if dist >= len { + break; + } + let dot1 = egui::pos2(from.x + ux * dist, from.y + uy * dist); + painter.circle_filled(dot1, radius, stroke.color); + dist += 3.0; + if dist >= len { + break; + } + let dot2 = egui::pos2(from.x + ux * dist, from.y + uy * dist); + painter.circle_filled(dot2, radius, stroke.color); + dist += 4.0; + } +} + +pub fn draw_room_crosshatch( + painter: &egui::Painter, + geometry: &GridGeometry, + room: &crate::layout::Room, + stroke: Stroke, +) { + for x in room.x..(room.x + room.width) { + for y in room.y..(room.y + room.height) { + let rect = cell_rect(geometry, x, y).shrink(2.0); + painter.line_segment([rect.left_top(), rect.right_bottom()], stroke); + painter.line_segment([rect.right_top(), rect.left_bottom()], stroke); + } + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..42b3040 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,436 @@ +use eframe::egui; +use serde::{Deserialize, Serialize}; + +pub mod tabs; +pub mod widgets; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Tab { + Generate, + Layout, + StartAndEnd, + MonstersAndTraps, + Add, +} + +impl Default for Tab { + 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 { + fn default() -> Self { + Self::Png + } +} + +impl ExportFormat { + pub fn extension(self) -> &'static str { + match self { + ExportFormat::Png => "png", + ExportFormat::Jpeg => "jpeg", + ExportFormat::Webp => "webp", + ExportFormat::Svg => "svg", + ExportFormat::Folder => "folder", + } + } + + pub fn label(self) -> &'static str { + match self { + ExportFormat::Png => ".png", + ExportFormat::Jpeg => ".jpeg", + ExportFormat::Webp => ".webp", + ExportFormat::Svg => ".svg", + ExportFormat::Folder => "Folder (Composite)", + } + } + + 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 { + fn default() -> Self { + Self::Png + } +} + +impl MaskFormat { + pub fn extension(self) -> &'static str { + match self { + MaskFormat::Png => "png", + MaskFormat::Jpeg => "jpeg", + MaskFormat::Webp => "webp", + } + } + + 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 { + 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, +} + +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 +} + +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 +} + +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.rs b/src/ui/tabs.rs similarity index 54% rename from src/ui.rs rename to src/ui/tabs.rs index b2297ea..3b6410d 100644 --- a/src/ui.rs +++ b/src/ui/tabs.rs @@ -1,433 +1,10 @@ +use super::{AddTool, ExportFormat, MaskFormat, SidePanelResult, UiSettings}; use crate::seed; use eframe::egui; -use egui::{Color32, RichText, Stroke}; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -enum Tab { - Generate, - Layout, - StartAndEnd, - MonstersAndTraps, - Add, -} - -impl Default for Tab { - // Start with the Generate tab selected. - 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 { - // Default export format is PNG. - fn default() -> Self { - Self::Png - } -} - -impl ExportFormat { - // Return the file extension for this export format. - pub fn extension(self) -> &'static str { - match self { - ExportFormat::Png => "png", - ExportFormat::Jpeg => "jpeg", - ExportFormat::Webp => "webp", - ExportFormat::Svg => "svg", - ExportFormat::Folder => "folder", - } - } - - // Return the UI label for this export format. - pub fn label(self) -> &'static str { - match self { - ExportFormat::Png => ".png", - ExportFormat::Jpeg => ".jpeg", - ExportFormat::Webp => ".webp", - ExportFormat::Svg => ".svg", - ExportFormat::Folder => "Folder (Composite)", - } - } - - // Report whether this export format supports a raster resolution. - 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 { - // Default mask format is PNG. - fn default() -> Self { - Self::Png - } -} - -impl MaskFormat { - // Return the file extension for this mask format. - pub fn extension(self) -> &'static str { - match self { - MaskFormat::Png => "png", - MaskFormat::Jpeg => "jpeg", - MaskFormat::Webp => "webp", - } - } - - // Return the UI label for this mask format. - 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, - active_tab: Tab, -} - -impl Default for UiSettings { - 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, -} - -pub fn draw_side_panel( - ctx: &egui::Context, - settings: &mut UiSettings, - export_progress: Option<(usize, usize)>, - num_levels: usize, -) -> SidePanelResult { - // Render the left side panel and return user interaction results. - 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| { - let is_generate = settings.active_tab == Tab::Generate; - let is_layout = settings.active_tab == Tab::Layout; - - if ui.selectable_label(is_generate, "Generate").clicked() { - settings.active_tab = Tab::Generate; - } - if ui.selectable_label(is_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 => { - draw_generate_tab(ui, settings, &mut result, export_progress, num_levels) - } - Tab::Layout => draw_layout_tab(ui, settings, &mut result), - Tab::StartAndEnd => draw_start_and_end_tab(ui, settings, &mut result), - Tab::MonstersAndTraps => draw_monsters_and_traps_tab(ui, settings, &mut result), - Tab::Add => draw_add_tab(ui, settings, &mut result), - } - }); - }); - - result -} - -// Draw mode controls and the legend panel. -pub fn draw_legend_panel(ctx: &egui::Context, settings: &mut UiSettings) -> bool { - let mut composition_mode_changed = false; - - 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 { - draw_legend_entry_colorblind(ui, "Rooms", LegendStyle::Crosshatch); - draw_legend_entry_colorblind(ui, "Corridors", LegendStyle::Dots); - draw_legend_entry_colorblind(ui, "Walls", LegendStyle::SolidLine); - draw_legend_entry_colorblind(ui, "Doors", LegendStyle::LongDash); - draw_legend_entry_colorblind(ui, "Locked Doors", LegendStyle::ShortDash); - draw_legend_entry_colorblind(ui, "Secret Doors", LegendStyle::DashDotDotLine); - draw_legend_entry_colorblind(ui, "Archways", LegendStyle::DottedLine); - draw_legend_entry_colorblind(ui, "Windows", LegendStyle::DashDotLine); - draw_legend_entry_colorblind(ui, "Stairs", LegendStyle::LabelChar('\u{1F5CF}')); - draw_legend_entry_colorblind(ui, "Start Marker", LegendStyle::Label("S")); - draw_legend_entry_colorblind(ui, "End Marker", LegendStyle::Label("E")); - draw_legend_entry_colorblind(ui, "Trap", LegendStyle::Label("T")); - draw_legend_entry_colorblind(ui, "Monster", LegendStyle::Label("M")); - } else { - draw_legend_entry(ui, Color32::from_rgb(70, 120, 160), "Rooms"); - draw_legend_entry(ui, Color32::from_rgb(210, 190, 120), "Corridors"); - draw_legend_entry(ui, Color32::BLACK, "Walls"); - draw_legend_entry(ui, Color32::from_rgb(80, 200, 120), "Doors"); - draw_legend_entry(ui, Color32::from_rgb(220, 70, 70), "Locked Doors"); - draw_legend_entry(ui, Color32::from_rgb(170, 80, 170), "Secret Doors"); - draw_legend_entry(ui, Color32::from_rgb(230, 140, 60), "Archways"); - draw_legend_entry(ui, Color32::from_rgb(70, 130, 220), "Windows"); - draw_legend_entry(ui, Color32::from_rgb(200, 150, 50), "Stairs"); - draw_legend_entry(ui, Color32::from_rgb(60, 220, 200), "Start Marker"); - draw_legend_entry(ui, Color32::from_rgb(240, 90, 90), "End Marker"); - draw_legend_entry(ui, Color32::from_rgb(150, 80, 230), "Trap"); - 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 -} - -// Draw level tabs and return the hovered tab index. -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 -} +use egui::RichText; // Render the Generate tab controls. -fn draw_generate_tab( +pub fn draw_generate_tab( ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut SidePanelResult, @@ -649,7 +226,7 @@ fn draw_generate_tab( } // Render the Layout tab controls. -fn draw_layout_tab(ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut SidePanelResult) { +pub fn draw_layout_tab(ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut SidePanelResult) { ui.label(RichText::new("Levels").strong()); ui.add_space(8.0); @@ -1075,7 +652,7 @@ fn draw_layout_tab(ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut Si } // Render the Add tab controls. -fn draw_add_tab(ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut SidePanelResult) { +pub fn draw_add_tab(ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut SidePanelResult) { ui.label(RichText::new("Add Tools").strong()); ui.add_space(8.0); @@ -1203,282 +780,7 @@ fn draw_add_tab(ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut SideP } } -// Draw a colored legend entry. -fn draw_legend_entry(ui: &mut egui::Ui, color: Color32, label: &str) { - ui.horizontal(|ui| { - let (rect, _resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); - ui.painter().rect_filled(rect, 0.0, color); - ui.painter().rect_stroke( - rect, - 0.0, - Stroke::new(1.0, Color32::WHITE), - egui::StrokeKind::Middle, - ); - ui.add_space(6.0); - ui.label(label); - }); - ui.add_space(4.0); -} - -#[derive(Debug, Clone, Copy)] -enum LegendStyle { - Crosshatch, - Dots, - SolidLine, - LongDash, - ShortDash, - DottedLine, - DashDotLine, - DashDotDotLine, - Label(&'static str), - LabelChar(char), -} - -// Draw a patterned legend entry. -fn draw_legend_entry_colorblind(ui: &mut egui::Ui, label: &str, style: LegendStyle) { - ui.horizontal(|ui| { - let (rect, _resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); - ui.painter().rect_filled(rect, 0.0, Color32::from_gray(220)); - ui.painter().rect_stroke( - rect, - 0.0, - Stroke::new(1.0, Color32::BLACK), - egui::StrokeKind::Middle, - ); - - match style { - LegendStyle::Crosshatch => { - ui.painter().line_segment( - [rect.left_top(), rect.right_bottom()], - Stroke::new(1.2, Color32::BLACK), - ); - ui.painter().line_segment( - [rect.right_top(), rect.left_bottom()], - Stroke::new(1.2, Color32::BLACK), - ); - } - LegendStyle::Dots => { - ui.painter() - .circle_filled(rect.center(), 2.0, Color32::BLACK); - ui.painter().circle_filled( - egui::pos2(rect.center().x - 4.0, rect.center().y), - 1.4, - Color32::BLACK, - ); - ui.painter().circle_filled( - egui::pos2(rect.center().x + 4.0, rect.center().y), - 1.4, - Color32::BLACK, - ); - } - LegendStyle::SolidLine => { - ui.painter().line_segment( - [ - egui::pos2(rect.left() + 1.0, rect.center().y), - egui::pos2(rect.right() - 1.0, rect.center().y), - ], - Stroke::new(2.0, Color32::BLACK), - ); - } - LegendStyle::Label(prefix) => { - ui.painter() - .circle_filled(rect.center(), 6.0, Color32::from_gray(180)); - ui.painter() - .circle_stroke(rect.center(), 6.0, Stroke::new(1.0, Color32::BLACK)); - let font_size = 10.0; - let font_id = egui::FontId::monospace(font_size); - let text_width = prefix.len() as f32 * font_size * 0.6; - let text_height = font_size * 0.8; - let text_pos = egui::pos2( - rect.center().x - text_width / 2.0, - rect.center().y - text_height / 2.0 + font_size / 5.0, - ); - ui.painter().text( - text_pos, - egui::Align2::LEFT_TOP, - prefix, - font_id, - Color32::BLACK, - ); - } - LegendStyle::LabelChar(character) => { - ui.painter() - .circle_filled(rect.center(), 6.0, Color32::from_gray(180)); - ui.painter() - .circle_stroke(rect.center(), 6.0, Stroke::new(1.0, Color32::BLACK)); - let font_size = 10.0; - let font_id = egui::FontId::monospace(font_size); - let text_width = font_size * 0.6; - let text_height = font_size * 0.8; - let text_pos = egui::pos2( - rect.center().x - text_width / 2.0, - rect.center().y - text_height / 2.0 + font_size / 5.0, - ); - ui.painter().text( - text_pos, - egui::Align2::LEFT_TOP, - character, - font_id, - Color32::BLACK, - ); - } - LegendStyle::LongDash => draw_dashed_line( - ui.painter(), - egui::pos2(rect.left() + 1.0, rect.center().y), - egui::pos2(rect.right() - 1.0, rect.center().y), - Stroke::new(2.0, Color32::BLACK), - 6.0, - 3.0, - ), - LegendStyle::ShortDash => draw_dashed_line( - ui.painter(), - egui::pos2(rect.left() + 1.0, rect.center().y), - egui::pos2(rect.right() - 1.0, rect.center().y), - Stroke::new(2.0, Color32::BLACK), - 3.0, - 2.0, - ), - LegendStyle::DottedLine => draw_dotted_line( - ui.painter(), - egui::pos2(rect.left() + 1.0, rect.center().y), - egui::pos2(rect.right() - 1.0, rect.center().y), - Stroke::new(2.0, Color32::BLACK), - ), - LegendStyle::DashDotLine => draw_dash_dot_line( - ui.painter(), - egui::pos2(rect.left() + 1.0, rect.center().y), - egui::pos2(rect.right() - 1.0, rect.center().y), - Stroke::new(2.0, Color32::BLACK), - ), - LegendStyle::DashDotDotLine => draw_dash_dot_dot_line( - ui.painter(), - egui::pos2(rect.left() + 1.0, rect.center().y), - egui::pos2(rect.right() - 1.0, rect.center().y), - Stroke::new(2.0, Color32::BLACK), - ), - } - - ui.add_space(6.0); - ui.label(label); - }); - ui.add_space(4.0); -} - -// Draw a dashed line. -fn draw_dashed_line( - painter: &egui::Painter, - from: egui::Pos2, - to: egui::Pos2, - stroke: Stroke, - dash_len: f32, - gap_len: f32, -) { - let dx = to.x - from.x; - let dy = to.y - from.y; - let len = (dx * dx + dy * dy).sqrt(); - if len <= 0.0 { - return; - } - let ux = dx / len; - let uy = dy / len; - - let mut dist = 0.0_f32; - while dist < len { - let seg_start = dist; - let seg_end = (dist + dash_len).min(len); - let p0 = egui::pos2(from.x + ux * seg_start, from.y + uy * seg_start); - let p1 = egui::pos2(from.x + ux * seg_end, from.y + uy * seg_end); - painter.line_segment([p0, p1], stroke); - dist += dash_len + gap_len; - } -} - -// Draw a dotted line. -fn draw_dotted_line(painter: &egui::Painter, from: egui::Pos2, to: egui::Pos2, stroke: Stroke) { - let dx = to.x - from.x; - let dy = to.y - from.y; - let len = (dx * dx + dy * dy).sqrt(); - if len <= 0.0 { - return; - } - let ux = dx / len; - let uy = dy / len; - let step = 5.0_f32; - let radius = (stroke.width * 0.35).max(1.0); - - let mut dist = 0.0_f32; - while dist <= len { - let point = egui::pos2(from.x + ux * dist, from.y + uy * dist); - painter.circle_filled(point, radius, stroke.color); - dist += step; - } -} - -fn draw_dash_dot_line(painter: &egui::Painter, from: egui::Pos2, to: egui::Pos2, stroke: Stroke) { - let dx = to.x - from.x; - let dy = to.y - from.y; - let len = (dx * dx + dy * dy).sqrt(); - if len <= 0.0 { - return; - } - - let ux = dx / len; - let uy = dy / len; - let mut dist = 0.0_f32; - while dist < len { - let dash_end = (dist + 7.0).min(len); - let p0 = egui::pos2(from.x + ux * dist, from.y + uy * dist); - let p1 = egui::pos2(from.x + ux * dash_end, from.y + uy * dash_end); - painter.line_segment([p0, p1], stroke); - dist = dash_end + 3.0; - if dist >= len { - break; - } - let dot = egui::pos2(from.x + ux * dist, from.y + uy * dist); - painter.circle_filled(dot, (stroke.width * 0.35).max(1.0), stroke.color); - dist += 4.0; - } -} - -fn draw_dash_dot_dot_line( - painter: &egui::Painter, - from: egui::Pos2, - to: egui::Pos2, - stroke: Stroke, -) { - let dx = to.x - from.x; - let dy = to.y - from.y; - let len = (dx * dx + dy * dy).sqrt(); - if len <= 0.0 { - return; - } - - let ux = dx / len; - let uy = dy / len; - let radius = (stroke.width * 0.35).max(1.0); - let mut dist = 0.0_f32; - while dist < len { - let dash_end = (dist + 7.0).min(len); - let p0 = egui::pos2(from.x + ux * dist, from.y + uy * dist); - let p1 = egui::pos2(from.x + ux * dash_end, from.y + uy * dash_end); - painter.line_segment([p0, p1], stroke); - dist = dash_end + 3.0; - if dist >= len { - break; - } - let dot1 = egui::pos2(from.x + ux * dist, from.y + uy * dist); - painter.circle_filled(dot1, radius, stroke.color); - dist += 3.0; - if dist >= len { - break; - } - let dot2 = egui::pos2(from.x + ux * dist, from.y + uy * dist); - painter.circle_filled(dot2, radius, stroke.color); - dist += 4.0; - } -} - -fn draw_start_and_end_tab( +pub fn draw_start_and_end_tab( ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut SidePanelResult, @@ -1560,7 +862,7 @@ fn draw_start_and_end_tab( ui.label("Markers are generated inside rooms. Matching start/end indices are paired as far apart as possible."); } -fn draw_monsters_and_traps_tab( +pub fn draw_monsters_and_traps_tab( ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut SidePanelResult, @@ -1623,7 +925,7 @@ fn draw_monsters_and_traps_tab( ); } -fn draw_usize_slider_row( +pub fn draw_usize_slider_row( ui: &mut egui::Ui, result: &mut SidePanelResult, label: &str, diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs new file mode 100644 index 0000000..d3e1b97 --- /dev/null +++ b/src/ui/widgets.rs @@ -0,0 +1,282 @@ +use eframe::egui; +use egui::{Color32, Stroke}; + +// Draw a colored legend entry. +pub fn draw_legend_entry(ui: &mut egui::Ui, color: Color32, label: &str) { + ui.horizontal(|ui| { + let (rect, _resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + ui.painter().rect_filled(rect, 0.0, color); + ui.painter().rect_stroke( + rect, + 0.0, + Stroke::new(1.0, Color32::WHITE), + egui::StrokeKind::Middle, + ); + ui.add_space(6.0); + ui.label(label); + }); + ui.add_space(4.0); +} + +#[derive(Debug, Clone, Copy)] +pub enum LegendStyle { + Crosshatch, + Dots, + SolidLine, + LongDash, + ShortDash, + DottedLine, + DashDotLine, + DashDotDotLine, + Label(&'static str), + LabelChar(char), +} + +// Draw a patterned legend entry. +pub fn draw_legend_entry_colorblind(ui: &mut egui::Ui, label: &str, style: LegendStyle) { + ui.horizontal(|ui| { + let (rect, _resp) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + ui.painter().rect_filled(rect, 0.0, Color32::from_gray(220)); + ui.painter().rect_stroke( + rect, + 0.0, + Stroke::new(1.0, Color32::BLACK), + egui::StrokeKind::Middle, + ); + + match style { + LegendStyle::Crosshatch => { + ui.painter().line_segment( + [rect.left_top(), rect.right_bottom()], + Stroke::new(1.2, Color32::BLACK), + ); + ui.painter().line_segment( + [rect.right_top(), rect.left_bottom()], + Stroke::new(1.2, Color32::BLACK), + ); + } + LegendStyle::Dots => { + ui.painter() + .circle_filled(rect.center(), 2.0, Color32::BLACK); + ui.painter().circle_filled( + egui::pos2(rect.center().x - 4.0, rect.center().y), + 1.4, + Color32::BLACK, + ); + ui.painter().circle_filled( + egui::pos2(rect.center().x + 4.0, rect.center().y), + 1.4, + Color32::BLACK, + ); + } + LegendStyle::SolidLine => { + ui.painter().line_segment( + [ + egui::pos2(rect.left() + 1.0, rect.center().y), + egui::pos2(rect.right() - 1.0, rect.center().y), + ], + Stroke::new(2.0, Color32::BLACK), + ); + } + LegendStyle::Label(prefix) => { + ui.painter() + .circle_filled(rect.center(), 6.0, Color32::from_gray(180)); + ui.painter() + .circle_stroke(rect.center(), 6.0, Stroke::new(1.0, Color32::BLACK)); + let font_size = 10.0; + let font_id = egui::FontId::monospace(font_size); + let text_width = prefix.len() as f32 * font_size * 0.6; + let text_height = font_size * 0.8; + let text_pos = egui::pos2( + rect.center().x - text_width / 2.0, + rect.center().y - text_height / 2.0 + font_size / 5.0, + ); + ui.painter().text( + text_pos, + egui::Align2::LEFT_TOP, + prefix, + font_id, + Color32::BLACK, + ); + } + LegendStyle::LabelChar(character) => { + ui.painter() + .circle_filled(rect.center(), 6.0, Color32::from_gray(180)); + ui.painter() + .circle_stroke(rect.center(), 6.0, Stroke::new(1.0, Color32::BLACK)); + let font_size = 10.0; + let font_id = egui::FontId::monospace(font_size); + let text_width = font_size * 0.6; + let text_height = font_size * 0.8; + let text_pos = egui::pos2( + rect.center().x - text_width / 2.0, + rect.center().y - text_height / 2.0 + font_size / 5.0, + ); + ui.painter().text( + text_pos, + egui::Align2::LEFT_TOP, + character, + font_id, + Color32::BLACK, + ); + } + LegendStyle::LongDash => draw_dashed_line( + ui.painter(), + egui::pos2(rect.left() + 1.0, rect.center().y), + egui::pos2(rect.right() - 1.0, rect.center().y), + Stroke::new(2.0, Color32::BLACK), + 6.0, + 3.0, + ), + LegendStyle::ShortDash => draw_dashed_line( + ui.painter(), + egui::pos2(rect.left() + 1.0, rect.center().y), + egui::pos2(rect.right() - 1.0, rect.center().y), + Stroke::new(2.0, Color32::BLACK), + 3.0, + 2.0, + ), + LegendStyle::DottedLine => draw_dotted_line( + ui.painter(), + egui::pos2(rect.left() + 1.0, rect.center().y), + egui::pos2(rect.right() - 1.0, rect.center().y), + Stroke::new(2.0, Color32::BLACK), + ), + LegendStyle::DashDotLine => draw_dash_dot_line( + ui.painter(), + egui::pos2(rect.left() + 1.0, rect.center().y), + egui::pos2(rect.right() - 1.0, rect.center().y), + Stroke::new(2.0, Color32::BLACK), + ), + LegendStyle::DashDotDotLine => draw_dash_dot_dot_line( + ui.painter(), + egui::pos2(rect.left() + 1.0, rect.center().y), + egui::pos2(rect.right() - 1.0, rect.center().y), + Stroke::new(2.0, Color32::BLACK), + ), + } + + ui.add_space(6.0); + ui.label(label); + }); + ui.add_space(4.0); +} + +// Draw a dashed line. +pub fn draw_dashed_line( + painter: &egui::Painter, + from: egui::Pos2, + to: egui::Pos2, + stroke: Stroke, + dash_len: f32, + gap_len: f32, +) { + let dx = to.x - from.x; + let dy = to.y - from.y; + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + return; + } + let ux = dx / len; + let uy = dy / len; + + let mut dist = 0.0_f32; + while dist < len { + let seg_start = dist; + let seg_end = (dist + dash_len).min(len); + let p0 = egui::pos2(from.x + ux * seg_start, from.y + uy * seg_start); + let p1 = egui::pos2(from.x + ux * seg_end, from.y + uy * seg_end); + painter.line_segment([p0, p1], stroke); + dist += dash_len + gap_len; + } +} + +// Draw a dotted line. +pub fn draw_dotted_line(painter: &egui::Painter, from: egui::Pos2, to: egui::Pos2, stroke: Stroke) { + let dx = to.x - from.x; + let dy = to.y - from.y; + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + return; + } + let ux = dx / len; + let uy = dy / len; + let step = 5.0_f32; + let radius = (stroke.width * 0.35).max(1.0); + + let mut dist = 0.0_f32; + while dist <= len { + let point = egui::pos2(from.x + ux * dist, from.y + uy * dist); + painter.circle_filled(point, radius, stroke.color); + dist += step; + } +} + +pub fn draw_dash_dot_line( + painter: &egui::Painter, + from: egui::Pos2, + to: egui::Pos2, + stroke: Stroke, +) { + let dx = to.x - from.x; + let dy = to.y - from.y; + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + return; + } + + let ux = dx / len; + let uy = dy / len; + let mut dist = 0.0_f32; + while dist < len { + let dash_end = (dist + 7.0).min(len); + let p0 = egui::pos2(from.x + ux * dist, from.y + uy * dist); + let p1 = egui::pos2(from.x + ux * dash_end, from.y + uy * dash_end); + painter.line_segment([p0, p1], stroke); + dist = dash_end + 3.0; + if dist >= len { + break; + } + let dot = egui::pos2(from.x + ux * dist, from.y + uy * dist); + painter.circle_filled(dot, (stroke.width * 0.35).max(1.0), stroke.color); + dist += 4.0; + } +} + +pub fn draw_dash_dot_dot_line( + painter: &egui::Painter, + from: egui::Pos2, + to: egui::Pos2, + stroke: Stroke, +) { + let dx = to.x - from.x; + let dy = to.y - from.y; + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + return; + } + + let ux = dx / len; + let uy = dy / len; + let radius = (stroke.width * 0.35).max(1.0); + let mut dist = 0.0_f32; + while dist < len { + let dash_end = (dist + 7.0).min(len); + let p0 = egui::pos2(from.x + ux * dist, from.y + uy * dist); + let p1 = egui::pos2(from.x + ux * dash_end, from.y + uy * dash_end); + painter.line_segment([p0, p1], stroke); + dist = dash_end + 3.0; + if dist >= len { + break; + } + let dot1 = egui::pos2(from.x + ux * dist, from.y + uy * dist); + painter.circle_filled(dot1, radius, stroke.color); + dist += 3.0; + if dist >= len { + break; + } + let dot2 = egui::pos2(from.x + ux * dist, from.y + uy * dist); + painter.circle_filled(dot2, radius, stroke.color); + dist += 4.0; + } +}