diff --git a/src/exporter.rs b/src/exporter.rs index b9860b7..302d859 100644 --- a/src/exporter.rs +++ b/src/exporter.rs @@ -6,7 +6,7 @@ use image::{DynamicImage, ImageFormat, RgbaImage, imageops::FilterType}; use rfd::FileDialog; use tiny_skia::{FillRule, Paint, PathBuilder, Pixmap, Rect, Stroke, StrokeDash, Transform}; -use crate::layout::{Door, DungeonLayout, Room, Window, WindowSide, corridor_cells}; +use crate::layout::{AreaMarker, Door, DungeonLayout, Room, Window, WindowSide, corridor_cells}; use crate::ui::{ExportFormat, MaskFormat, UiSettings}; const BG: (u8, u8, u8, u8) = (24, 24, 26, 255); @@ -20,6 +20,8 @@ const SECRET_DOOR: (u8, u8, u8, u8) = (170, 80, 170, 255); const ARCHWAY: (u8, u8, u8, u8) = (230, 140, 60, 255); const WINDOW: (u8, u8, u8, u8) = (70, 130, 220, 255); const WHITE: (u8, u8, u8, u8) = (255, 255, 255, 255); +const START_MARKER: (u8, u8, u8, u8) = (60, 220, 200, 255); +const END_MARKER: (u8, u8, u8, u8) = (240, 90, 90, 255); pub enum ExportTarget { File(PathBuf), @@ -274,6 +276,13 @@ fn render_pixmap(layout: &DungeonLayout, settings: &UiSettings) -> Result String { } } + if let Some(marker) = layout.start_marker.as_ref() { + append_svg_area_marker(&mut s, &g, marker, "S", START_MARKER, settings.colorblind_mode); + } + if let Some(marker) = layout.end_marker.as_ref() { + append_svg_area_marker(&mut s, &g, marker, "E", END_MARKER, settings.colorblind_mode); + } + s.push_str("\n"); s } @@ -746,6 +762,91 @@ fn fill_rect_room(pixmap: &mut Pixmap, g: &ExportGeometry, room: &Room, color: ( pixmap.fill_rect(rect, &paint, Transform::identity(), None); } +fn fill_rect_marker( + pixmap: &mut Pixmap, + g: &ExportGeometry, + marker: &AreaMarker, + color: (u8, u8, u8, u8), +) { + let Some(rect) = Rect::from_xywh( + g.left() + marker.cell.0 as f32 * g.cell, + g.top() + marker.cell.1 as f32 * g.cell, + marker.size as f32 * g.cell, + marker.size as f32 * g.cell, + ) else { + return; + }; + let mut paint = Paint::default(); + paint.set_color_rgba8(color.0, color.1, color.2, color.3); + pixmap.fill_rect(rect, &paint, Transform::identity(), None); +} + +fn draw_area_marker( + pixmap: &mut Pixmap, + g: &ExportGeometry, + marker: &AreaMarker, + label: &str, + color: (u8, u8, u8, u8), + colorblind_mode: bool, +) { + let overlay = (color.0, color.1, color.2, if colorblind_mode { 56 } else { 90 }); + fill_rect_marker(pixmap, g, marker, overlay); + + let x = g.left() + marker.cell.0 as f32 * g.cell; + let y = g.top() + marker.cell.1 as f32 * g.cell; + let w = marker.size as f32 * g.cell; + let h = marker.size as f32 * g.cell; + draw_wall_segment(pixmap, x, y, x + w, y, 3.0, color); + draw_wall_segment(pixmap, x, y + h, x + w, y + h, 3.0, color); + draw_wall_segment(pixmap, x, y, x, y + h, 3.0, color); + draw_wall_segment(pixmap, x + w, y, x + w, y + h, 3.0, color); + + let symbol = if colorblind_mode { BLACK } else { WHITE }; + if label == "S" { + draw_line( + pixmap, + x + w * 0.25, + y + h * 0.75, + x + w * 0.5, + y + h * 0.25, + 4.0, + symbol, + DoorStyle::Solid, + ); + draw_line( + pixmap, + x + w * 0.5, + y + h * 0.25, + x + w * 0.75, + y + h * 0.75, + 4.0, + symbol, + DoorStyle::Solid, + ); + } else { + draw_line( + pixmap, + x + w * 0.25, + y + h * 0.25, + x + w * 0.75, + y + h * 0.75, + 4.0, + symbol, + DoorStyle::Solid, + ); + draw_line( + pixmap, + x + w * 0.75, + y + h * 0.25, + x + w * 0.25, + y + h * 0.75, + 4.0, + symbol, + DoorStyle::Solid, + ); + } +} + // Draw a filled circle for dot patterns. fn draw_dot(pixmap: &mut Pixmap, cx: f32, cy: f32, radius: f32, color: (u8, u8, u8, u8)) { let mut pb = PathBuilder::new(); @@ -1007,6 +1108,56 @@ fn append_svg_walls( } } +fn append_svg_area_marker( + out: &mut String, + g: &ExportGeometry, + marker: &AreaMarker, + label: &str, + color: (u8, u8, u8, u8), + colorblind_mode: bool, +) { + let x = g.left() + marker.cell.0 as f32 * g.cell; + let y = g.top() + marker.cell.1 as f32 * g.cell; + let w = marker.size as f32 * g.cell; + let h = marker.size as f32 * g.cell; + let alpha = if colorblind_mode { 0.22 } else { 0.35 }; + let _ = writeln!( + out, + "", + color.0, color.1, color.2, color.0, color.1, color.2 + ); + let symbol = if colorblind_mode { "black" } else { "white" }; + if label == "S" { + let _ = writeln!( + out, + "", + x + w * 0.25, + y + h * 0.75, + x + w * 0.5, + y + h * 0.25, + x + w * 0.75, + y + h * 0.75 + ); + } else { + let _ = writeln!( + out, + "", + x + w * 0.25, + y + h * 0.25, + x + w * 0.75, + y + h * 0.75 + ); + let _ = writeln!( + out, + "", + x + w * 0.75, + y + h * 0.25, + x + w * 0.25, + y + h * 0.75 + ); + } +} + // Draw a door line onto the pixmap. fn draw_door( pixmap: &mut Pixmap, diff --git a/src/layout.rs b/src/layout.rs index 531540d..de94add 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -54,6 +54,12 @@ pub struct TextLabel { pub font_size: u16, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AreaMarker { + pub cell: (usize, usize), + pub size: usize, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum WindowSide { Left, @@ -88,6 +94,8 @@ pub struct DungeonLayout { pub doors: Vec, pub windows: Vec, pub text_labels: Vec, + pub start_marker: Option, + pub end_marker: Option, pub packed_rooms: bool, } @@ -99,6 +107,8 @@ impl Default for DungeonLayout { doors: Vec::new(), windows: Vec::new(), text_labels: Vec::new(), + start_marker: None, + end_marker: None, packed_rooms: false, } } @@ -166,6 +176,8 @@ pub fn generate_layout( doors: Vec::new(), windows: Vec::new(), text_labels: Vec::new(), + start_marker: None, + end_marker: None, packed_rooms: pack_rooms_without_corridors, }; } @@ -189,6 +201,8 @@ pub fn generate_layout( doors: Vec::new(), windows: Vec::new(), text_labels: Vec::new(), + start_marker: None, + end_marker: None, packed_rooms: pack_rooms_without_corridors, }; } @@ -217,6 +231,8 @@ pub fn generate_layout( doors: Vec::new(), windows: Vec::new(), text_labels: Vec::new(), + start_marker: None, + end_marker: None, packed_rooms: true, }; apply_doors(&mut layout, seed, door_settings, cols, rows); @@ -274,6 +290,8 @@ pub fn generate_layout( doors: Vec::new(), windows: Vec::new(), text_labels: Vec::new(), + start_marker: None, + end_marker: None, packed_rooms: false, }; } @@ -328,6 +346,8 @@ pub fn generate_layout( doors: Vec::new(), windows: Vec::new(), text_labels: Vec::new(), + start_marker: None, + end_marker: None, packed_rooms: false, }; apply_doors(&mut layout, seed, door_settings, cols, rows); diff --git a/src/main.rs b/src/main.rs index 31e0cd2..3fa5288 100644 --- a/src/main.rs +++ b/src/main.rs @@ -57,6 +57,7 @@ struct DungeonApp { hover_corridor_idx: Option, hover_door_idx: Option, hover_text_idx: Option, + hover_marker_kind: Option, } impl Default for DungeonApp { @@ -94,6 +95,7 @@ impl Default for DungeonApp { hover_corridor_idx: None, hover_door_idx: None, hover_text_idx: None, + hover_marker_kind: None, } } } @@ -203,6 +205,8 @@ impl eframe::App for DungeonApp { 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, "Start Marker", LegendStyle::SolidLine); + draw_legend_entry_colorblind(ui, "End Marker", LegendStyle::SolidLine); } else { draw_legend_entry(ui, Color32::from_rgb(70, 120, 160), "Rooms"); draw_legend_entry(ui, Color32::from_rgb(210, 190, 120), "Corridors"); @@ -212,13 +216,15 @@ impl eframe::App for DungeonApp { 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(60, 220, 200), "Start Marker"); + draw_legend_entry(ui, Color32::from_rgb(240, 90, 90), "End Marker"); } 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 or corridor"); + 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"); @@ -249,6 +255,7 @@ impl eframe::App for DungeonApp { &self.layout, self.settings.colorblind_mode, self.hover_text_idx, + self.hover_marker_kind, ); self.draw_add_overlay(&painter, &geometry); self.draw_resize_overlay(&painter, &geometry); @@ -290,6 +297,7 @@ impl DungeonApp { self.hover_corridor_idx = None; self.hover_door_idx = None; self.hover_text_idx = None; + self.hover_marker_kind = None; self.pending_delete = false; } @@ -323,6 +331,7 @@ impl DungeonApp { self.hover_corridor_idx = None; self.hover_door_idx = None; self.hover_text_idx = None; + self.hover_marker_kind = None; self.pending_delete = false; } @@ -670,6 +679,22 @@ impl DungeonApp { self.settings.add_tool = AddTool::None; } } + AddTool::StartMarker | AddTool::EndMarker => { + 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, + _ => unreachable!(), + }; + self.add_marker_at_cell(cell, kind); + self.settings.add_tool = AddTool::None; + } + } AddTool::None => { self.add_corridor_drag = None; } @@ -797,6 +822,26 @@ impl DungeonApp { }); } + fn add_marker_at_cell(&mut self, cell: (usize, usize), kind: MarkerKind) { + if cell.0 >= self.settings.cols || cell.1 >= self.settings.rows { + return; + } + + let size = match kind { + MarkerKind::Start => self.settings.start_marker_size, + MarkerKind::End => self.settings.end_marker_size, + } + .clamp(1, 10); + let origin = centered_marker_origin(cell, size, self.settings.cols, self.settings.rows); + let marker = layout::AreaMarker { cell: origin, size }; + + self.push_undo_snapshot(); + match kind { + MarkerKind::Start => self.layout.start_marker = Some(marker), + MarkerKind::End => self.layout.end_marker = Some(marker), + } + } + // Update which room/corridor/door is currently hovered. fn update_hover_targets(&mut self, ctx: &egui::Context, geometry: &GridGeometry) { let Some(pointer_pos) = ctx.pointer_hover_pos() else { @@ -804,6 +849,7 @@ impl DungeonApp { self.hover_corridor_idx = None; self.hover_door_idx = None; self.hover_text_idx = None; + self.hover_marker_kind = None; return; }; if let Some(edge) = self.door_edge_at_pointer(pointer_pos, geometry) { @@ -817,12 +863,22 @@ impl DungeonApp { self.hover_room_idx = None; self.hover_corridor_idx = None; self.hover_text_idx = None; + self.hover_marker_kind = 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_kind = None; + return; + } + + self.hover_marker_kind = self.marker_at_pointer(pointer_pos, geometry); + if self.hover_marker_kind.is_some() { self.hover_room_idx = None; self.hover_corridor_idx = None; self.hover_door_idx = None; @@ -833,14 +889,17 @@ impl DungeonApp { self.hover_room_idx = Some(room_idx); self.hover_corridor_idx = None; self.hover_door_idx = None; + self.hover_marker_kind = 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_kind = None; } else { self.hover_corridor_idx = None; self.hover_door_idx = None; + self.hover_marker_kind = None; } } } @@ -854,6 +913,20 @@ impl DungeonApp { .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); + marker_contains_cell(self.layout.start_marker.as_ref(), clicked) + .then_some(MarkerKind::Start) + .or_else(|| { + marker_contains_cell(self.layout.end_marker.as_ref(), clicked).then_some(MarkerKind::End) + }) + } + // Handle right-click resizing of rooms and return whether resizing is active. fn handle_resize( &mut self, @@ -1168,6 +1241,15 @@ impl DungeonApp { return; } + if let Some(kind) = self.marker_at_pointer(pointer_pos, geometry) { + self.push_undo_snapshot(); + match kind { + MarkerKind::Start => self.layout.start_marker = None, + MarkerKind::End => self.layout.end_marker = None, + } + return; + } + if let Some(edge) = self.door_edge_at_pointer(pointer_pos, geometry) { let target = normalized_edge(edge.0, edge.1); if let Some(idx) = self.layout.doors.iter().position(|d| { @@ -1400,6 +1482,12 @@ enum ResizeCorner { BottomRight, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MarkerKind { + Start, + End, +} + #[derive(Debug, Clone)] struct RoomResizeState { room_idx: usize, @@ -1491,6 +1579,7 @@ fn draw_layout( layout: &DungeonLayout, colorblind_mode: bool, hover_text_idx: Option, + hover_marker_kind: Option, ) { let wall_width_px = (geometry.cell_size / 5.0).max(1.0); let door_width_px = (geometry.cell_size / 10.0).max(1.0); @@ -1742,6 +1831,29 @@ fn draw_layout( color, ); } + + if let Some(marker) = layout.start_marker.as_ref() { + draw_area_marker( + painter, + geometry, + marker, + "S", + Color32::from_rgb(60, 220, 200), + colorblind_mode, + hover_marker_kind == Some(MarkerKind::Start), + ); + } + if let Some(marker) = layout.end_marker.as_ref() { + draw_area_marker( + painter, + geometry, + marker, + "E", + Color32::from_rgb(240, 90, 90), + colorblind_mode, + hover_marker_kind == Some(MarkerKind::End), + ); + } } // Compute the rectangle for a given grid cell. @@ -1754,6 +1866,82 @@ fn cell_rect(geometry: &GridGeometry, col: usize, row: usize) -> egui::Rect { ) } +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, + ); +} + // Draw a rectangular wall segment between two points. fn draw_wall_segment( painter: &egui::Painter, diff --git a/src/ui.rs b/src/ui.rs index 54a3e1c..be1a21c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,6 +8,7 @@ enum Tab { Generate, Layout, Add, + MonstersAndTraps, } impl Default for Tab { @@ -131,6 +132,8 @@ pub struct UiSettings { pub export_show_grid: bool, pub add_tool: AddTool, pub add_text_value: String, + pub start_marker_size: usize, + pub end_marker_size: usize, active_tab: Tab, } @@ -170,6 +173,8 @@ impl Default for UiSettings { export_show_grid: true, add_tool: AddTool::None, add_text_value: "Text".to_string(), + start_marker_size: 1, + end_marker_size: 1, active_tab: Tab::Generate, } } @@ -185,6 +190,8 @@ pub enum AddTool { LockedDoor, SecretDoor, Text, + StartMarker, + EndMarker, } #[derive(Debug, Clone, Copy, Default)] @@ -213,6 +220,7 @@ pub fn draw_side_panel( let is_generate = settings.active_tab == Tab::Generate; let is_layout = settings.active_tab == Tab::Layout; let is_add = settings.active_tab == Tab::Add; + let is_monsters_and_traps = settings.active_tab == Tab::MonstersAndTraps; if ui.selectable_label(is_generate, "Generate").clicked() { settings.active_tab = Tab::Generate; @@ -223,6 +231,12 @@ pub fn draw_side_panel( if ui.selectable_label(is_add, "Add").clicked() { settings.active_tab = Tab::Add; } + if ui + .selectable_label(is_monsters_and_traps, "Monsters & Traps") + .clicked() + { + settings.active_tab = Tab::MonstersAndTraps; + } }); ui.separator(); @@ -230,6 +244,7 @@ pub fn draw_side_panel( Tab::Generate => draw_generate_tab(ui, settings, &mut result, export_in_progress), Tab::Layout => draw_layout_tab(ui, settings, &mut result), Tab::Add => draw_add_tab(ui, settings), + Tab::MonstersAndTraps => draw_monsters_and_traps_tab(ui, settings), } }); @@ -744,6 +759,72 @@ fn draw_add_tab(ui: &mut egui::Ui, settings: &mut UiSettings) { ui.label("Note: Left click on the canvas to place the text."); response } + AddTool::StartMarker | AddTool::EndMarker => { + ui.label("Use the Monsters & Traps tab to place start and end markers.") + } AddTool::None => ui.label("Select a tool to add rooms or corridors."), }; } + +fn draw_monsters_and_traps_tab(ui: &mut egui::Ui, settings: &mut UiSettings) { + ui.label(RichText::new("Start / End Markers").strong()); + ui.add_space(8.0); + + ui.label("Start Marker Size"); + ui.horizontal(|ui| { + ui.add(egui::Slider::new(&mut settings.start_marker_size, 1..=10).show_value(false)); + ui.add( + egui::DragValue::new(&mut settings.start_marker_size) + .speed(1.0) + .range(1..=10), + ); + }); + + ui.add_space(4.0); + if ui + .selectable_label(settings.add_tool == AddTool::StartMarker, "Place Start Marker") + .clicked() + { + settings.add_tool = if settings.add_tool == AddTool::StartMarker { + AddTool::None + } else { + AddTool::StartMarker + }; + } + + ui.add_space(10.0); + ui.label("End Marker Size"); + ui.horizontal(|ui| { + ui.add(egui::Slider::new(&mut settings.end_marker_size, 1..=10).show_value(false)); + ui.add( + egui::DragValue::new(&mut settings.end_marker_size) + .speed(1.0) + .range(1..=10), + ); + }); + + ui.add_space(4.0); + if ui + .selectable_label(settings.add_tool == AddTool::EndMarker, "Place End Marker") + .clicked() + { + settings.add_tool = if settings.add_tool == AddTool::EndMarker { + AddTool::None + } else { + AddTool::EndMarker + }; + } + + ui.add_space(8.0); + match settings.add_tool { + AddTool::StartMarker => { + ui.label("Click the grid to place the start marker. It is centered on the clicked cell."); + } + AddTool::EndMarker => { + ui.label("Click the grid to place the end marker. It is centered on the clicked cell."); + } + _ => { + ui.label("Monsters, traps, and bosses will live here next. For now, place start and end markers."); + } + } +}