broke everything from mod.rs files into multiple smaller files

This commit is contained in:
grimsace
2026-05-19 08:30:46 -05:00
parent 5d50c46f39
commit 66f77a177a
14 changed files with 2293 additions and 2232 deletions
+4 -485
View File
@@ -1,495 +1,14 @@
/*
* Main export module.
* Orchestrates the export process, coordinating dialog selection,
* background thread execution, and multi-format (SVG/Raster) generation.
* Export module facade.
* Re-exports export targets, events, and image/SVG export entry points.
*/
use image::ImageFormat;
use rayon::prelude::*;
use rfd::FileDialog;
use std::path::PathBuf;
use std::sync::mpsc;
use tiny_skia::Pixmap;
use crate::layout::DungeonLayout;
use crate::ui::{ExportFormat, MaskFormat, UiSettings};
pub mod pipeline;
pub mod raster;
pub mod svg;
pub mod types;
pub mod utils;
pub use pipeline::{export_to_target, select_export_target};
pub use svg::build_svg;
pub use types::*;
type ProgressSender = mpsc::Sender<ExportEvent>;
// Selects export target.
pub fn select_export_target(settings: &UiSettings) -> Result<ExportTarget, String> {
if settings.export_format == ExportFormat::Folder || settings.export_level_index == 999 {
let title = if settings.export_level_index == 999 {
"Select export folder for all levels"
} else {
"Export composite masks folder"
};
let mut dialog = FileDialog::new().set_title(title);
if let Some(ref last_path) = settings.last_export_path {
dialog = dialog.set_directory(last_path);
}
let folder = dialog
.pick_folder()
.ok_or_else(|| "Export canceled".to_string())?;
return Ok(ExportTarget::Folder(folder));
}
let ext = settings.export_format.extension();
let default_name = format!("dungeon_export.{ext}");
let mut dialog = FileDialog::new();
dialog = dialog
.set_title("Export image")
.set_file_name(&default_name)
.add_filter(settings.export_format.label(), &[ext]);
if let Some(ref last_path) = settings.last_export_path {
dialog = dialog.set_directory(last_path);
}
let mut path = dialog
.save_file()
.ok_or_else(|| "Export canceled".to_string())?;
if path.extension().is_none() {
path.set_extension(ext);
}
Ok(ExportTarget::File(path))
}
// Exports to target.
pub fn export_to_target(
layouts: &[DungeonLayout],
settings: &UiSettings,
target: ExportTarget,
progress_tx: &ProgressSender,
) -> Result<PathBuf, String> {
let export_indices: Vec<usize> = if settings.export_level_index == 999 {
(0..layouts.len()).collect()
} else {
let idx = settings
.export_level_index
.min(layouts.len().saturating_sub(1));
vec![idx]
};
let total = export_image_count(settings, &target, export_indices.len());
report_progress(progress_tx, 0, total);
match target {
ExportTarget::Folder(folder) => {
if settings.export_format == ExportFormat::Folder {
export_composite_masks_for_levels(
layouts,
settings,
&folder,
&export_indices,
progress_tx,
)?;
} else {
export_level_images_to_folder(
layouts,
settings,
&folder,
&export_indices,
progress_tx,
)?;
}
Ok(folder)
}
ExportTarget::File(path) => {
let idx = export_indices[0];
save_layout_to_file(&layouts[idx], settings, &path)?;
report_progress(progress_tx, 1, total);
Ok(path)
}
}
}
// Exports image count.
fn export_image_count(settings: &UiSettings, target: &ExportTarget, level_count: usize) -> usize {
match target {
ExportTarget::File(_) => 1,
ExportTarget::Folder(_) if settings.export_format == ExportFormat::Folder => {
level_count * composite_images_per_level(settings)
}
ExportTarget::Folder(_) => level_count,
}
}
// Counts images per level.
fn composite_images_per_level(settings: &UiSettings) -> usize {
let base_masks = 12;
let grid_mask = usize::from(settings.export_show_grid);
let composite = 1;
base_masks + grid_mask + composite
}
// Sends an export progress update to the UI channel.
fn report_progress(progress_tx: &ProgressSender, completed: usize, total: usize) {
let _ = progress_tx.send(ExportEvent::Progress(ExportProgress { completed, total }));
}
// Exports level images to folder.
fn export_level_images_to_folder(
layouts: &[DungeonLayout],
settings: &UiSettings,
folder: &std::path::Path,
export_indices: &[usize],
progress_tx: &ProgressSender,
) -> Result<(), String> {
std::fs::create_dir_all(folder).map_err(|err| format!("Failed creating folder: {err}"))?;
let completed = std::sync::atomic::AtomicUsize::new(0);
let total = export_indices.len();
let ext = settings.export_format.extension();
export_indices.par_iter().try_for_each(|&idx| {
let filename = format!("level{}.{ext}", idx + 1);
let path = folder.join(filename);
save_layout_to_file(&layouts[idx], settings, &path)?;
let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
report_progress(progress_tx, done, total);
Ok(())
})
}
// Saves layout to file.
fn save_layout_to_file(
layout: &DungeonLayout,
settings: &UiSettings,
path: &std::path::Path,
) -> Result<(), String> {
match settings.export_format {
ExportFormat::Svg => {
let svg = build_svg(layout, settings);
std::fs::write(path, svg).map_err(|e| format!("Failed writing SVG: {e}"))?;
}
ExportFormat::Png | ExportFormat::Jpeg | ExportFormat::Webp => {
let pixmap = raster::render_pixmap(layout, settings)?;
let img = raster::raster_image_from_pixmap(&pixmap, settings)?;
let format = match settings.export_format {
ExportFormat::Png => ImageFormat::Png,
ExportFormat::Jpeg => ImageFormat::Jpeg,
ExportFormat::Webp => ImageFormat::WebP,
ExportFormat::Svg | ExportFormat::Folder => unreachable!(),
};
img.save_with_format(path, format)
.map_err(|e| format!("Failed writing image: {e}"))?;
}
ExportFormat::Folder => unreachable!(),
}
Ok(())
}
// Exports composite masks for levels.
fn export_composite_masks_for_levels(
layouts: &[DungeonLayout],
settings: &UiSettings,
folder: &std::path::Path,
export_indices: &[usize],
progress_tx: &ProgressSender,
) -> Result<(), String> {
let contexts = export_indices
.iter()
.map(|&idx| {
let subfolder = if settings.export_level_index == 999 {
folder.join(format!("level {}", idx + 1))
} else {
folder.to_path_buf()
};
std::fs::create_dir_all(&subfolder)
.map_err(|err| format!("Failed creating folder: {err}"))?;
let geometry = ExportGeometry::new(settings.cols, settings.rows);
let scene = utils::collect_scene_data(&layouts[idx], settings);
Ok(CompositeExportContext {
layout: &layouts[idx],
folder: subfolder,
geometry,
scene,
wall_width: (geometry.cell / 2.5).max(1.0),
door_width: (geometry.cell / 5.0).max(1.0),
})
})
.collect::<Result<Vec<_>, String>>()?;
let mut tasks = vec![
MaskTask::Floors,
MaskTask::Walls,
MaskTask::Doors,
MaskTask::Archways,
MaskTask::LockedDoors,
MaskTask::SecretDoors,
MaskTask::Windows,
MaskTask::StartMarkers,
MaskTask::EndMarkers,
MaskTask::TrapMarkers,
MaskTask::MonsterMarkers,
MaskTask::Stairs,
];
if settings.export_show_grid {
tasks.push(MaskTask::Grid);
}
let export_tasks = contexts
.iter()
.enumerate()
.flat_map(|(context_idx, _)| {
tasks
.iter()
.copied()
.map(move |task| (context_idx, CompositeTask::Mask(task)))
.chain(std::iter::once((context_idx, CompositeTask::Composite)))
})
.collect::<Vec<_>>();
let completed = std::sync::atomic::AtomicUsize::new(0);
let total = export_tasks.len();
export_tasks
.into_par_iter()
.try_for_each(|(context_idx, task)| {
let context = &contexts[context_idx];
match task {
CompositeTask::Mask(mask_task) => {
let (stem, pixmap) = render_mask_pixmap(context, mask_task)?;
save_mask_image(&context.folder, stem, &pixmap, settings)
}
CompositeTask::Composite => save_composite_image(context, settings),
}?;
let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
report_progress(progress_tx, done, total);
Ok::<(), String>(())
})?;
Ok(())
}
// Saves composite image.
fn save_composite_image(
context: &CompositeExportContext<'_>,
settings: &UiSettings,
) -> Result<(), String> {
let composite = raster::render_pixmap_with_scene(
context.layout,
settings,
&context.geometry,
&context.scene,
)?;
let composite_img = raster::raster_image_from_pixmap(&composite, settings)?;
let composite_ext = settings.mask_format.extension();
let composite_path = context.folder.join(format!("composite.{composite_ext}"));
composite_img
.save_with_format(&composite_path, mask_image_format(settings.mask_format))
.map_err(|e| format!("Failed writing {}: {e}", composite_path.display()))?;
Ok(())
}
// Renders mask pixmap.
fn render_mask_pixmap(
context: &CompositeExportContext<'_>,
task: MaskTask,
) -> Result<(&'static str, Pixmap), String> {
let g = &context.geometry;
let scene = &context.scene;
let layout = context.layout;
let result = match task {
MaskTask::Floors => {
let mut pixmap = blank_mask(g, "floors")?;
for &(x, y) in &scene.corridor_cells {
raster::fill_rect_cell(&mut pixmap, g, x, y, WHITE);
}
for room in &layout.rooms {
raster::fill_rect_room(&mut pixmap, g, room, WHITE);
}
("floors_mask", pixmap)
}
MaskTask::Walls => {
let mut pixmap = blank_mask(g, "walls")?;
raster::draw_cell_walls(
&mut pixmap,
g,
&scene.corridor_cells,
Some(&scene.corridor_edges),
&scene.door_edges,
context.wall_width,
WHITE,
);
raster::draw_cell_walls(
&mut pixmap,
g,
&scene.room_cells,
Some(&scene.room_edges),
&scene.door_edges,
context.wall_width,
WHITE,
);
("walls_mask", pixmap)
}
MaskTask::Doors => {
let mut pixmap = blank_mask(g, "doors")?;
for door in &layout.doors {
if !door.secret && !door.archway && !door.locked {
raster::draw_door(
&mut pixmap,
g,
door,
context.door_width,
WHITE,
DoorStyle::Solid,
);
}
}
("doors_mask", pixmap)
}
MaskTask::Archways => {
let mut pixmap = blank_mask(g, "archways")?;
for door in &layout.doors {
if door.archway {
raster::draw_door(
&mut pixmap,
g,
door,
context.door_width,
WHITE,
DoorStyle::Solid,
);
}
}
("archways_mask", pixmap)
}
MaskTask::LockedDoors => {
let mut pixmap = blank_mask(g, "locked doors")?;
for door in &layout.doors {
if door.locked {
raster::draw_door(
&mut pixmap,
g,
door,
context.door_width,
WHITE,
DoorStyle::Solid,
);
}
}
("locked_doors_mask", pixmap)
}
MaskTask::SecretDoors => {
let mut pixmap = blank_mask(g, "secret doors")?;
for door in &layout.doors {
if door.secret {
raster::draw_door(
&mut pixmap,
g,
door,
context.door_width,
WHITE,
DoorStyle::Solid,
);
}
}
("secret_doors_mask", pixmap)
}
MaskTask::Windows => {
let mut pixmap = blank_mask(g, "windows")?;
for window in &layout.windows {
raster::draw_window(
&mut pixmap,
g,
window,
context.door_width,
WHITE,
DoorStyle::Solid,
);
}
("windows_mask", pixmap)
}
MaskTask::StartMarkers => {
let mut pixmap = blank_mask(g, "start markers")?;
fill_marker_mask(&mut pixmap, g, &layout.start_markers);
("start_markers_mask", pixmap)
}
MaskTask::EndMarkers => {
let mut pixmap = blank_mask(g, "end markers")?;
fill_marker_mask(&mut pixmap, g, &layout.end_markers);
("end_markers_mask", pixmap)
}
MaskTask::TrapMarkers => {
let mut pixmap = blank_mask(g, "trap markers")?;
fill_marker_mask(&mut pixmap, g, &layout.trap_markers);
("trap_markers_mask", pixmap)
}
MaskTask::MonsterMarkers => {
let mut pixmap = blank_mask(g, "monster markers")?;
fill_marker_mask(&mut pixmap, g, &layout.monster_markers);
("monster_markers_mask", pixmap)
}
MaskTask::Stairs => {
let mut pixmap = blank_mask(g, "stairs")?;
for staircase in &layout.stairs {
raster::fill_rect_staircase(&mut pixmap, g, staircase, WHITE);
}
("stairs_mask", pixmap)
}
MaskTask::Grid => {
let mut pixmap = blank_mask(g, "grid")?;
raster::draw_grid_color(&mut pixmap, g, WHITE);
("grid_mask", pixmap)
}
};
Ok(result)
}
// Creates an empty pixmap for a named export mask.
fn blank_mask(g: &ExportGeometry, name: &str) -> Result<Pixmap, String> {
let mut pixmap = Pixmap::new(g.width, g.height)
.ok_or_else(|| format!("Failed to allocate {name} mask canvas"))?;
raster::fill_bg(&mut pixmap, BLACK);
Ok(pixmap)
}
// Fills marker mask.
fn fill_marker_mask(
pixmap: &mut Pixmap,
g: &ExportGeometry,
markers: &[crate::layout::AreaMarker],
) {
for marker in markers {
raster::fill_rect_marker(pixmap, g, marker, WHITE);
}
}
// Saves mask image.
fn save_mask_image(
folder: &std::path::Path,
stem: &str,
pixmap: &Pixmap,
settings: &UiSettings,
) -> Result<(), String> {
let ext = settings.mask_format.extension();
let path = folder.join(format!("{stem}.{ext}"));
let img = raster::raster_mask_image_from_pixmap(pixmap, settings)?;
img.save_with_format(&path, mask_image_format(settings.mask_format))
.map_err(|e| format!("Failed writing {}: {e}", path.display()))?;
Ok(())
}
// Selects image format.
fn mask_image_format(format: MaskFormat) -> ImageFormat {
match format {
MaskFormat::Png => ImageFormat::Png,
MaskFormat::Jpeg => ImageFormat::Jpeg,
MaskFormat::Webp => ImageFormat::WebP,
}
}
+491
View File
@@ -0,0 +1,491 @@
/*
* Main export module.
* Orchestrates the export process, coordinating dialog selection,
* background thread execution, and multi-format (SVG/Raster) generation.
*/
use image::ImageFormat;
use rayon::prelude::*;
use rfd::FileDialog;
use std::path::PathBuf;
use std::sync::mpsc;
use tiny_skia::Pixmap;
use crate::layout::DungeonLayout;
use crate::ui::{ExportFormat, MaskFormat, UiSettings};
use super::svg::build_svg;
use super::types::*;
use super::{raster, utils};
type ProgressSender = mpsc::Sender<ExportEvent>;
// Selects export target.
pub fn select_export_target(settings: &UiSettings) -> Result<ExportTarget, String> {
if settings.export_format == ExportFormat::Folder || settings.export_level_index == 999 {
let title = if settings.export_level_index == 999 {
"Select export folder for all levels"
} else {
"Export composite masks folder"
};
let mut dialog = FileDialog::new().set_title(title);
if let Some(ref last_path) = settings.last_export_path {
dialog = dialog.set_directory(last_path);
}
let folder = dialog
.pick_folder()
.ok_or_else(|| "Export canceled".to_string())?;
return Ok(ExportTarget::Folder(folder));
}
let ext = settings.export_format.extension();
let default_name = format!("dungeon_export.{ext}");
let mut dialog = FileDialog::new();
dialog = dialog
.set_title("Export image")
.set_file_name(&default_name)
.add_filter(settings.export_format.label(), &[ext]);
if let Some(ref last_path) = settings.last_export_path {
dialog = dialog.set_directory(last_path);
}
let mut path = dialog
.save_file()
.ok_or_else(|| "Export canceled".to_string())?;
if path.extension().is_none() {
path.set_extension(ext);
}
Ok(ExportTarget::File(path))
}
// Exports to target.
pub fn export_to_target(
layouts: &[DungeonLayout],
settings: &UiSettings,
target: ExportTarget,
progress_tx: &ProgressSender,
) -> Result<PathBuf, String> {
let export_indices: Vec<usize> = if settings.export_level_index == 999 {
(0..layouts.len()).collect()
} else {
let idx = settings
.export_level_index
.min(layouts.len().saturating_sub(1));
vec![idx]
};
let total = export_image_count(settings, &target, export_indices.len());
report_progress(progress_tx, 0, total);
match target {
ExportTarget::Folder(folder) => {
if settings.export_format == ExportFormat::Folder {
export_composite_masks_for_levels(
layouts,
settings,
&folder,
&export_indices,
progress_tx,
)?;
} else {
export_level_images_to_folder(
layouts,
settings,
&folder,
&export_indices,
progress_tx,
)?;
}
Ok(folder)
}
ExportTarget::File(path) => {
let idx = export_indices[0];
save_layout_to_file(&layouts[idx], settings, &path)?;
report_progress(progress_tx, 1, total);
Ok(path)
}
}
}
// Exports image count.
fn export_image_count(settings: &UiSettings, target: &ExportTarget, level_count: usize) -> usize {
match target {
ExportTarget::File(_) => 1,
ExportTarget::Folder(_) if settings.export_format == ExportFormat::Folder => {
level_count * composite_images_per_level(settings)
}
ExportTarget::Folder(_) => level_count,
}
}
// Counts images per level.
fn composite_images_per_level(settings: &UiSettings) -> usize {
let base_masks = 12;
let grid_mask = usize::from(settings.export_show_grid);
let composite = 1;
base_masks + grid_mask + composite
}
// Sends an export progress update to the UI channel.
fn report_progress(progress_tx: &ProgressSender, completed: usize, total: usize) {
let _ = progress_tx.send(ExportEvent::Progress(ExportProgress { completed, total }));
}
// Exports level images to folder.
fn export_level_images_to_folder(
layouts: &[DungeonLayout],
settings: &UiSettings,
folder: &std::path::Path,
export_indices: &[usize],
progress_tx: &ProgressSender,
) -> Result<(), String> {
std::fs::create_dir_all(folder).map_err(|err| format!("Failed creating folder: {err}"))?;
let completed = std::sync::atomic::AtomicUsize::new(0);
let total = export_indices.len();
let ext = settings.export_format.extension();
export_indices.par_iter().try_for_each(|&idx| {
let filename = format!("level{}.{ext}", idx + 1);
let path = folder.join(filename);
save_layout_to_file(&layouts[idx], settings, &path)?;
let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
report_progress(progress_tx, done, total);
Ok(())
})
}
// Saves layout to file.
fn save_layout_to_file(
layout: &DungeonLayout,
settings: &UiSettings,
path: &std::path::Path,
) -> Result<(), String> {
match settings.export_format {
ExportFormat::Svg => {
let svg = build_svg(layout, settings);
std::fs::write(path, svg).map_err(|e| format!("Failed writing SVG: {e}"))?;
}
ExportFormat::Png | ExportFormat::Jpeg | ExportFormat::Webp => {
let pixmap = raster::render_pixmap(layout, settings)?;
let img = raster::raster_image_from_pixmap(&pixmap, settings)?;
let format = match settings.export_format {
ExportFormat::Png => ImageFormat::Png,
ExportFormat::Jpeg => ImageFormat::Jpeg,
ExportFormat::Webp => ImageFormat::WebP,
ExportFormat::Svg | ExportFormat::Folder => unreachable!(),
};
img.save_with_format(path, format)
.map_err(|e| format!("Failed writing image: {e}"))?;
}
ExportFormat::Folder => unreachable!(),
}
Ok(())
}
// Exports composite masks for levels.
fn export_composite_masks_for_levels(
layouts: &[DungeonLayout],
settings: &UiSettings,
folder: &std::path::Path,
export_indices: &[usize],
progress_tx: &ProgressSender,
) -> Result<(), String> {
let contexts = export_indices
.iter()
.map(|&idx| {
let subfolder = if settings.export_level_index == 999 {
folder.join(format!("level {}", idx + 1))
} else {
folder.to_path_buf()
};
std::fs::create_dir_all(&subfolder)
.map_err(|err| format!("Failed creating folder: {err}"))?;
let geometry = ExportGeometry::new(settings.cols, settings.rows);
let scene = utils::collect_scene_data(&layouts[idx], settings);
Ok(CompositeExportContext {
layout: &layouts[idx],
folder: subfolder,
geometry,
scene,
wall_width: (geometry.cell / 2.5).max(1.0),
door_width: (geometry.cell / 5.0).max(1.0),
})
})
.collect::<Result<Vec<_>, String>>()?;
let mut tasks = vec![
MaskTask::Floors,
MaskTask::Walls,
MaskTask::Doors,
MaskTask::Archways,
MaskTask::LockedDoors,
MaskTask::SecretDoors,
MaskTask::Windows,
MaskTask::StartMarkers,
MaskTask::EndMarkers,
MaskTask::TrapMarkers,
MaskTask::MonsterMarkers,
MaskTask::Stairs,
];
if settings.export_show_grid {
tasks.push(MaskTask::Grid);
}
let export_tasks = contexts
.iter()
.enumerate()
.flat_map(|(context_idx, _)| {
tasks
.iter()
.copied()
.map(move |task| (context_idx, CompositeTask::Mask(task)))
.chain(std::iter::once((context_idx, CompositeTask::Composite)))
})
.collect::<Vec<_>>();
let completed = std::sync::atomic::AtomicUsize::new(0);
let total = export_tasks.len();
export_tasks
.into_par_iter()
.try_for_each(|(context_idx, task)| {
let context = &contexts[context_idx];
match task {
CompositeTask::Mask(mask_task) => {
let (stem, pixmap) = render_mask_pixmap(context, mask_task)?;
save_mask_image(&context.folder, stem, &pixmap, settings)
}
CompositeTask::Composite => save_composite_image(context, settings),
}?;
let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
report_progress(progress_tx, done, total);
Ok::<(), String>(())
})?;
Ok(())
}
// Saves composite image.
fn save_composite_image(
context: &CompositeExportContext<'_>,
settings: &UiSettings,
) -> Result<(), String> {
let composite = raster::render_pixmap_with_scene(
context.layout,
settings,
&context.geometry,
&context.scene,
)?;
let composite_img = raster::raster_image_from_pixmap(&composite, settings)?;
let composite_ext = settings.mask_format.extension();
let composite_path = context.folder.join(format!("composite.{composite_ext}"));
composite_img
.save_with_format(&composite_path, mask_image_format(settings.mask_format))
.map_err(|e| format!("Failed writing {}: {e}", composite_path.display()))?;
Ok(())
}
// Renders mask pixmap.
fn render_mask_pixmap(
context: &CompositeExportContext<'_>,
task: MaskTask,
) -> Result<(&'static str, Pixmap), String> {
let g = &context.geometry;
let scene = &context.scene;
let layout = context.layout;
let result = match task {
MaskTask::Floors => {
let mut pixmap = blank_mask(g, "floors")?;
for &(x, y) in &scene.corridor_cells {
raster::fill_rect_cell(&mut pixmap, g, x, y, WHITE);
}
for room in &layout.rooms {
raster::fill_rect_room(&mut pixmap, g, room, WHITE);
}
("floors_mask", pixmap)
}
MaskTask::Walls => {
let mut pixmap = blank_mask(g, "walls")?;
raster::draw_cell_walls(
&mut pixmap,
g,
&scene.corridor_cells,
Some(&scene.corridor_edges),
&scene.door_edges,
context.wall_width,
WHITE,
);
raster::draw_cell_walls(
&mut pixmap,
g,
&scene.room_cells,
Some(&scene.room_edges),
&scene.door_edges,
context.wall_width,
WHITE,
);
("walls_mask", pixmap)
}
MaskTask::Doors => {
let mut pixmap = blank_mask(g, "doors")?;
for door in &layout.doors {
if !door.secret && !door.archway && !door.locked {
raster::draw_door(
&mut pixmap,
g,
door,
context.door_width,
WHITE,
DoorStyle::Solid,
);
}
}
("doors_mask", pixmap)
}
MaskTask::Archways => {
let mut pixmap = blank_mask(g, "archways")?;
for door in &layout.doors {
if door.archway {
raster::draw_door(
&mut pixmap,
g,
door,
context.door_width,
WHITE,
DoorStyle::Solid,
);
}
}
("archways_mask", pixmap)
}
MaskTask::LockedDoors => {
let mut pixmap = blank_mask(g, "locked doors")?;
for door in &layout.doors {
if door.locked {
raster::draw_door(
&mut pixmap,
g,
door,
context.door_width,
WHITE,
DoorStyle::Solid,
);
}
}
("locked_doors_mask", pixmap)
}
MaskTask::SecretDoors => {
let mut pixmap = blank_mask(g, "secret doors")?;
for door in &layout.doors {
if door.secret {
raster::draw_door(
&mut pixmap,
g,
door,
context.door_width,
WHITE,
DoorStyle::Solid,
);
}
}
("secret_doors_mask", pixmap)
}
MaskTask::Windows => {
let mut pixmap = blank_mask(g, "windows")?;
for window in &layout.windows {
raster::draw_window(
&mut pixmap,
g,
window,
context.door_width,
WHITE,
DoorStyle::Solid,
);
}
("windows_mask", pixmap)
}
MaskTask::StartMarkers => {
let mut pixmap = blank_mask(g, "start markers")?;
fill_marker_mask(&mut pixmap, g, &layout.start_markers);
("start_markers_mask", pixmap)
}
MaskTask::EndMarkers => {
let mut pixmap = blank_mask(g, "end markers")?;
fill_marker_mask(&mut pixmap, g, &layout.end_markers);
("end_markers_mask", pixmap)
}
MaskTask::TrapMarkers => {
let mut pixmap = blank_mask(g, "trap markers")?;
fill_marker_mask(&mut pixmap, g, &layout.trap_markers);
("trap_markers_mask", pixmap)
}
MaskTask::MonsterMarkers => {
let mut pixmap = blank_mask(g, "monster markers")?;
fill_marker_mask(&mut pixmap, g, &layout.monster_markers);
("monster_markers_mask", pixmap)
}
MaskTask::Stairs => {
let mut pixmap = blank_mask(g, "stairs")?;
for staircase in &layout.stairs {
raster::fill_rect_staircase(&mut pixmap, g, staircase, WHITE);
}
("stairs_mask", pixmap)
}
MaskTask::Grid => {
let mut pixmap = blank_mask(g, "grid")?;
raster::draw_grid_color(&mut pixmap, g, WHITE);
("grid_mask", pixmap)
}
};
Ok(result)
}
// Creates an empty pixmap for a named export mask.
fn blank_mask(g: &ExportGeometry, name: &str) -> Result<Pixmap, String> {
let mut pixmap = Pixmap::new(g.width, g.height)
.ok_or_else(|| format!("Failed to allocate {name} mask canvas"))?;
raster::fill_bg(&mut pixmap, BLACK);
Ok(pixmap)
}
// Fills marker mask.
fn fill_marker_mask(
pixmap: &mut Pixmap,
g: &ExportGeometry,
markers: &[crate::layout::AreaMarker],
) {
for marker in markers {
raster::fill_rect_marker(pixmap, g, marker, WHITE);
}
}
// Saves mask image.
fn save_mask_image(
folder: &std::path::Path,
stem: &str,
pixmap: &Pixmap,
settings: &UiSettings,
) -> Result<(), String> {
let ext = settings.mask_format.extension();
let path = folder.join(format!("{stem}.{ext}"));
let img = raster::raster_mask_image_from_pixmap(pixmap, settings)?;
img.save_with_format(&path, mask_image_format(settings.mask_format))
.map_err(|e| format!("Failed writing {}: {e}", path.display()))?;
Ok(())
}
// Selects image format.
fn mask_image_format(format: MaskFormat) -> ImageFormat {
match format {
MaskFormat::Png => ImageFormat::Png,
MaskFormat::Jpeg => ImageFormat::Jpeg,
MaskFormat::Webp => ImageFormat::WebP,
}
}
+218 -2
View File
@@ -5,8 +5,8 @@
*/
use super::{
AddCorridorDrag, GridGeometry, HoverMarker, MarkerKind, centered_marker_origin,
corridor_drag_at_pointer, door_edge_at_pointer, door_edges_for, marker_at_pointer,
AddCorridorDrag, GridGeometry, HoverMarker, MarkerKind, cell_center, centered_marker_origin,
corridor_drag_at_pointer, door_edge_at_pointer, door_edges_for, marker_at_pointer, marker_rect,
normalized_edge, pointer_to_grid, room_at_pointer, stair_at_pointer, text_at_pointer,
};
use crate::app::{DungeonApp, ManualDoorKind};
@@ -16,6 +16,7 @@ use crate::layout::{
};
use crate::ui::AddTool;
use eframe::egui;
use egui::{Color32, Stroke};
// Handles add tool.
pub fn handle_add_tool(app: &mut DungeonApp, response: &egui::Response, geometry: &GridGeometry) {
@@ -549,3 +550,218 @@ pub fn manual_door_edge_allowed(
let b_is_feature = b_room.is_some() || b_in_corridor;
a_is_feature || b_is_feature
}
// Draws add overlay.
pub fn draw_add_overlay(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) {
if app.settings.add_tool != AddTool::Corridor {
return;
}
let Some(drag) = &app.add_corridor_drag else {
return;
};
let start = cell_center(geometry, drag.start_cell.0, drag.start_cell.1);
let end = cell_center(geometry, drag.current_cell.0, drag.current_cell.1);
let stroke = Stroke::new(2.0, Color32::WHITE);
painter.circle_filled(start, 3.0, Color32::WHITE);
crate::rendering::draw_dashed_line(painter, start, end, stroke, 8.0, 6.0);
}
// Draws add tool ghost.
pub fn draw_add_tool_ghost(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) {
let tool = app.settings.add_tool;
if tool == AddTool::None || tool == AddTool::Corridor {
return;
}
let Some(pointer_pos) = painter.ctx().pointer_hover_pos() else {
return;
};
let Some((grid_x, grid_y)) = pointer_to_grid(pointer_pos, geometry) else {
return;
};
let cell = (grid_x.floor() as usize, grid_y.floor() as usize);
let ghost_alpha = 0.35;
match tool {
AddTool::Room => {
let width = app.settings.add_room_width.max(1);
let height = app.settings.add_room_height.max(1);
let max_x = app.settings.cols.saturating_sub(cell.0);
let max_y = app.settings.rows.saturating_sub(cell.1);
let w = width.min(max_x).max(1);
let h = height.min(max_y).max(1);
let left = geometry.rect.left() + cell.0 as f32 * geometry.cell_size;
let top = geometry.rect.top() + cell.1 as f32 * geometry.cell_size;
let right = left + w as f32 * geometry.cell_size;
let bottom = top + h as f32 * geometry.cell_size;
let rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom));
let fill = Color32::from_rgb(70, 120, 160).gamma_multiply(ghost_alpha);
painter.rect_filled(rect, 4.0, fill);
painter.rect_stroke(
rect,
4.0,
Stroke::new(
1.5,
Color32::from_rgb(150, 200, 240).gamma_multiply(ghost_alpha + 0.3),
),
egui::StrokeKind::Middle,
);
}
AddTool::Text => {
let text = app.settings.add_text_value.trim();
if text.is_empty() {
return;
}
let font_size = app.settings.add_text_font_size.max(8);
let rect = crate::rendering::text_label_rect(
geometry,
&layout::TextLabel {
cell,
text: text.to_string(),
font_size,
},
);
let fill = Color32::from_rgb(50, 50, 70).gamma_multiply(ghost_alpha);
painter.rect_filled(rect.expand(4.0), 4.0, fill);
painter.rect_stroke(
rect.expand(4.0),
4.0,
Stroke::new(
1.0,
Color32::from_rgb(180, 180, 220).gamma_multiply(ghost_alpha + 0.2),
),
egui::StrokeKind::Middle,
);
let color = Color32::WHITE.gamma_multiply(ghost_alpha + 0.1);
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
text,
egui::FontId::proportional(font_size as f32),
color,
);
}
AddTool::StartMarker
| AddTool::EndMarker
| AddTool::TrapMarker
| AddTool::MonsterMarker => {
let size = app.settings.add_marker_size.max(1);
let origin = centered_marker_origin(cell, size, app.settings.cols, app.settings.rows);
let rect = marker_rect(&layout::AreaMarker { cell: origin, size }, geometry);
let base_color = match tool {
AddTool::StartMarker => Color32::from_rgb(60, 220, 200),
AddTool::EndMarker => Color32::from_rgb(240, 90, 90),
AddTool::TrapMarker => Color32::from_rgb(150, 80, 230),
AddTool::MonsterMarker => Color32::from_rgb(200, 50, 50),
_ => Color32::WHITE,
};
let fill = base_color.gamma_multiply(ghost_alpha);
painter.rect_filled(rect, 4.0, fill);
painter.rect_stroke(
rect,
4.0,
Stroke::new(
1.5,
Color32::from_rgb(255, 255, 255).gamma_multiply(ghost_alpha + 0.2),
),
egui::StrokeKind::Middle,
);
let label = match tool {
AddTool::StartMarker => "S",
AddTool::EndMarker => "E",
AddTool::TrapMarker => "T",
AddTool::MonsterMarker => "M",
_ => "?",
};
let text_color = Color32::WHITE.gamma_multiply(ghost_alpha + 0.15);
let font_size = (geometry.cell_size * size as f32 * 0.5).clamp(14.0, 72.0);
painter.text(
rect.center(),
egui::Align2::CENTER_CENTER,
label,
egui::FontId::proportional(font_size),
text_color,
);
}
AddTool::Staircase => {
let width = app.settings.add_stair_width.max(1);
let height = app.settings.add_stair_height.max(1);
let max_x = app.settings.cols.saturating_sub(cell.0);
let max_y = app.settings.rows.saturating_sub(cell.1);
let w = width.min(max_x).max(1);
let h = height.min(max_y).max(1);
let left = geometry.rect.left() + cell.0 as f32 * geometry.cell_size;
let top = geometry.rect.top() + cell.1 as f32 * geometry.cell_size;
let right = left + w as f32 * geometry.cell_size;
let bottom = top + h as f32 * geometry.cell_size;
let rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom));
let fill = Color32::from_rgb(200, 150, 50).gamma_multiply(ghost_alpha);
painter.rect_filled(rect, 4.0, fill);
painter.rect_stroke(
rect,
4.0,
Stroke::new(
1.5,
Color32::from_rgb(220, 180, 80).gamma_multiply(ghost_alpha + 0.2),
),
egui::StrokeKind::Middle,
);
let steps = (h as f32 * 2.0).round() as usize;
let step_height = (rect.height() / (steps as f32)).max(1.0);
let line_color = Color32::from_rgb(150, 100, 30).gamma_multiply(ghost_alpha + 0.15);
for i in 0..steps {
let y = rect.top() + (i as f32 * step_height) + step_height * 0.5;
painter.line_segment(
[
egui::pos2(rect.left() + 2.0, y),
egui::pos2(rect.right() - 2.0, y),
],
Stroke::new(1.5, line_color),
);
}
}
AddTool::Archway | AddTool::Door | AddTool::LockedDoor | AddTool::SecretDoor => {
if let Some((edge_a, edge_b)) = door_edge_at_pointer(app, pointer_pos, geometry) {
let door_width_cells = 1;
let style = match tool {
AddTool::Archway => crate::rendering::DoorLineStyle::Dotted,
AddTool::Door => crate::rendering::DoorLineStyle::LongDash,
AddTool::LockedDoor => crate::rendering::DoorLineStyle::ShortDash,
AddTool::SecretDoor => crate::rendering::DoorLineStyle::DashDotDot,
_ => crate::rendering::DoorLineStyle::Solid,
};
let color = match tool {
AddTool::Archway => Color32::from_rgb(230, 140, 60),
AddTool::Door => Color32::from_rgb(80, 200, 120),
AddTool::LockedDoor => Color32::from_rgb(220, 70, 70),
AddTool::SecretDoor => Color32::from_rgb(170, 80, 170),
_ => Color32::WHITE,
};
let ghost_color = color.gamma_multiply(ghost_alpha);
crate::rendering::draw_door_line(
painter,
geometry,
edge_a,
edge_b,
door_width_cells,
Stroke::new((geometry.cell_size / 5.0).max(1.0), ghost_color),
style,
);
}
}
AddTool::None | AddTool::Corridor => {}
}
}
+57 -3
View File
@@ -5,14 +5,15 @@
use super::{
CorridorDragState, DragState, GridGeometry, MarkerDragState, MarkerKind, RoomDragState,
StaircaseDragState, TextDragState, corridor_drag_at_pointer, marker_drag_at_pointer,
pointer_to_grid, reroute_path_through_cell, room_at_pointer, stair_drag_at_pointer,
text_at_pointer,
StaircaseDragState, TextDragState, cell_center, corridor_drag_at_pointer, door_render_width,
marker_drag_at_pointer, pointer_to_grid, reroute_path_through_cell, room_at_pointer,
stair_drag_at_pointer, text_at_pointer,
};
use crate::app::DungeonApp;
use crate::layout::{blocked_room_cells, shortest_path_cells};
use crate::ui::AddTool;
use eframe::egui;
use egui::{Color32, Stroke};
// Handles active drag gestures for rooms, corridors, markers, stairs, and text.
pub fn handle_drag(
@@ -327,3 +328,56 @@ pub fn drag_stair_to_pointer(
d.original_cell = new_cell;
}
}
// Draws corridor hover overlay.
pub fn draw_corridor_hover_overlay(
app: &DungeonApp,
painter: &egui::Painter,
geometry: &GridGeometry,
) {
let Some(idx) = app.hover_corridor_idx else {
return;
};
if app.settings.active_level_index >= app.levels.len() {
return;
}
let layout = &app.levels[app.settings.active_level_index];
let Some(corridor) = layout.corridors.get(idx) else {
return;
};
let color = Color32::from_gray(170);
let stroke = Stroke::new(2.0, color);
for pair in corridor.path.windows(2) {
let a = cell_center(geometry, pair[0].0, pair[0].1);
let b = cell_center(geometry, pair[1].0, pair[1].1);
crate::rendering::draw_dashed_line(painter, a, b, stroke, 6.0, 6.0);
}
}
// Draws door hover overlay.
pub fn draw_door_hover_overlay(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) {
let Some(idx) = app.hover_door_idx else {
return;
};
if app.settings.active_level_index >= app.levels.len() {
return;
}
let layout = &app.levels[app.settings.active_level_index];
let Some(door) = layout.doors.get(idx) else {
return;
};
let color = Color32::from_gray(200);
let stroke = Stroke::new(2.0, color);
crate::rendering::draw_door_line(
painter,
geometry,
door.from,
door.to,
door_render_width(door),
stroke,
crate::rendering::DoorLineStyle::LongDash,
);
}
+140
View File
@@ -0,0 +1,140 @@
/*
* Grid geometry and coordinate helpers.
* Owns screen/grid conversion and rectangle helpers used by interaction code.
*/
use crate::layout;
use eframe::egui;
use egui::{Color32, Stroke};
pub struct GridGeometry {
pub rect: egui::Rect,
pub cell_size: f32,
pub cols: usize,
pub rows: usize,
}
// Draws the editable grid and returns its screen geometry.
pub fn draw_grid(
painter: &egui::Painter,
area: egui::Rect,
cols: usize,
rows: usize,
) -> GridGeometry {
let cols = cols.max(1);
let rows = rows.max(1);
let cell_size = (area.width() / cols as f32).min(area.height() / rows as f32);
let grid_width = cell_size * cols as f32;
let grid_height = cell_size * rows as f32;
let grid_rect = egui::Rect::from_min_size(
egui::pos2(
area.center().x - (grid_width * 0.5),
area.center().y - (grid_height * 0.5),
),
egui::vec2(grid_width, grid_height),
);
let stroke = Stroke::new(1.0, Color32::from_gray(160));
painter.rect_stroke(grid_rect, 0.0, stroke, egui::StrokeKind::Middle);
for col in 1..cols {
let x = grid_rect.left() + (col as f32 * cell_size);
painter.line_segment(
[
egui::pos2(x, grid_rect.top()),
egui::pos2(x, grid_rect.bottom()),
],
stroke,
);
}
for row in 1..rows {
let y = grid_rect.top() + (row as f32 * cell_size);
painter.line_segment(
[
egui::pos2(grid_rect.left(), y),
egui::pos2(grid_rect.right(), y),
],
stroke,
);
}
GridGeometry {
rect: grid_rect,
cell_size,
cols,
rows,
}
}
// Converts to grid.
pub fn pointer_to_grid(pointer_pos: egui::Pos2, geometry: &GridGeometry) -> Option<(f32, f32)> {
if !geometry.rect.contains(pointer_pos) {
return None;
}
let x = (pointer_pos.x - geometry.rect.left()) / geometry.cell_size;
let y = (pointer_pos.y - geometry.rect.top()) / geometry.cell_size;
Some((x, y))
}
// Returns the screen center point for a grid cell.
pub fn cell_center(geometry: &GridGeometry, col: usize, row: usize) -> egui::Pos2 {
egui::pos2(
geometry.rect.left() + (col as f32 + 0.5) * geometry.cell_size,
geometry.rect.top() + (row as f32 + 0.5) * geometry.cell_size,
)
}
// Returns the screen rectangle for a grid cell.
pub fn cell_rect(geometry: &GridGeometry, col: usize, row: usize) -> egui::Rect {
let left = geometry.rect.left() + col as f32 * geometry.cell_size;
let top = geometry.rect.top() + row as f32 * geometry.cell_size;
egui::Rect::from_min_size(
egui::pos2(left, top),
egui::vec2(geometry.cell_size, geometry.cell_size),
)
}
// Computes the screen rectangle occupied by an area marker.
pub fn marker_rect(marker: &layout::AreaMarker, geometry: &GridGeometry) -> egui::Rect {
let left = geometry.rect.left() + marker.cell.0 as f32 * geometry.cell_size;
let top = geometry.rect.top() + marker.cell.1 as f32 * geometry.cell_size;
egui::Rect::from_min_size(
egui::pos2(left, top),
egui::vec2(
marker.size as f32 * geometry.cell_size,
marker.size as f32 * geometry.cell_size,
),
)
}
// Computes contains cell.
pub fn marker_contains_cell(marker: Option<&layout::AreaMarker>, cell: (usize, usize)) -> bool {
let Some(marker) = marker else {
return false;
};
cell.0 >= marker.cell.0
&& cell.0 < marker.cell.0 + marker.size
&& cell.1 >= marker.cell.1
&& cell.1 < marker.cell.1 + marker.size
}
// Computes marker origin.
pub fn centered_marker_origin(
cell: (usize, usize),
size: usize,
cols: usize,
rows: usize,
) -> (usize, usize) {
let size = size.clamp(1, 10).min(cols.max(1)).min(rows.max(1));
let half = size / 2;
let max_x = cols.saturating_sub(size);
let max_y = rows.saturating_sub(size);
(
cell.0.saturating_sub(half).min(max_x),
cell.1.saturating_sub(half).min(max_y),
)
}
+383
View File
@@ -0,0 +1,383 @@
/*
* Hit-testing and hover target detection for interactive dungeon editing.
*/
use super::{
CorridorDragState, GridGeometry, HoverMarker, MarkerDragState, MarkerKind, StaircaseDragState,
door_edges_for, marker_contains_cell, normalized_edge, pointer_to_grid, resize_hit_cells,
};
use crate::app::DungeonApp;
use eframe::egui;
// Handles update hover targets.
pub fn update_hover_targets(app: &mut DungeonApp, ctx: &egui::Context, geometry: &GridGeometry) {
if app.drag_state.is_some() || app.resize_state.is_some() || app.add_corridor_drag.is_some() {
return;
}
let Some(pointer_pos) = ctx.pointer_hover_pos() else {
app.clear_hover_targets();
return;
};
if app.settings.active_level_index >= app.levels.len() {
return;
}
let layout = &app.levels[app.settings.active_level_index];
if let Some(edge) = door_edge_at_pointer(app, pointer_pos, geometry) {
let target = normalized_edge(edge.0, edge.1);
app.hover_door_idx = layout.doors.iter().position(|d| {
door_edges_for(d, app.settings.cols, app.settings.rows)
.iter()
.any(|e| normalized_edge(e.0, e.1) == target)
});
if app.hover_door_idx.is_some() {
app.hover_room_idx = None;
app.hover_corridor_idx = None;
app.hover_text_idx = None;
app.hover_marker = None;
return;
}
}
app.hover_text_idx = text_at_pointer(app, pointer_pos, geometry);
if app.hover_text_idx.is_some() {
app.hover_room_idx = None;
app.hover_corridor_idx = None;
app.hover_door_idx = None;
app.hover_marker = None;
return;
}
app.hover_marker = marker_at_pointer(app, pointer_pos, geometry);
if app.hover_marker.is_some() {
app.hover_room_idx = None;
app.hover_corridor_idx = None;
app.hover_door_idx = None;
app.hover_stair_idx = None;
return;
}
app.hover_stair_idx = stair_at_pointer(app, pointer_pos, geometry);
if app.hover_stair_idx.is_some() {
app.hover_room_idx = None;
app.hover_corridor_idx = None;
app.hover_door_idx = None;
app.hover_marker = None;
return;
}
if let Some((room_idx, _, _)) = room_at_pointer(app, pointer_pos, geometry) {
app.hover_room_idx = Some(room_idx);
app.hover_corridor_idx = None;
app.hover_door_idx = None;
app.hover_marker = None;
} else {
app.hover_room_idx = None;
if let Some(corridor_drag) = corridor_drag_at_pointer(app, pointer_pos, geometry) {
app.hover_corridor_idx = Some(corridor_drag.corridor_index);
app.hover_door_idx = None;
app.hover_marker = None;
} else {
app.hover_corridor_idx = None;
app.hover_door_idx = None;
app.hover_marker = None;
}
}
}
// Computes edge at pointer.
pub fn door_edge_at_pointer(
app: &DungeonApp,
pointer_pos: egui::Pos2,
geometry: &GridGeometry,
) -> Option<((usize, usize), (usize, usize))> {
let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?;
let cell_x = grid_x.floor() as isize;
let cell_y = grid_y.floor() as isize;
if cell_x < 0
|| cell_y < 0
|| cell_x >= app.settings.cols as isize
|| cell_y >= app.settings.rows as isize
{
return None;
}
let fx = grid_x - grid_x.floor();
let fy = grid_y - grid_y.floor();
let left = fx;
let right = 1.0 - fx;
let top = fy;
let bottom = 1.0 - fy;
let mut candidates = [
(left, (cell_x - 1, cell_y), (cell_x, cell_y)),
(right, (cell_x + 1, cell_y), (cell_x, cell_y)),
(top, (cell_x, cell_y - 1), (cell_x, cell_y)),
(bottom, (cell_x, cell_y + 1), (cell_x, cell_y)),
];
candidates.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let (dist, a, b) = candidates[0];
if dist > 0.2 {
return None;
}
let (ax, ay) = a;
let (bx, by) = b;
if ax < 0
|| ay < 0
|| bx < 0
|| by < 0
|| ax >= app.settings.cols as isize
|| bx >= app.settings.cols as isize
|| ay >= app.settings.rows as isize
|| by >= app.settings.rows as isize
{
return None;
}
Some(((ax as usize, ay as usize), (bx as usize, by as usize)))
}
// Computes at pointer.
pub fn room_at_pointer(
app: &DungeonApp,
pointer_pos: egui::Pos2,
geometry: &GridGeometry,
) -> Option<(usize, f32, f32)> {
let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?;
if app.settings.active_level_index >= app.levels.len() {
return None;
}
let layout = &app.levels[app.settings.active_level_index];
for (room_idx, room) in layout.rooms.iter().enumerate().rev() {
let room_x = room.x as f32;
let room_y = room.y as f32;
let room_right = room_x + room.width as f32;
let room_bottom = room_y + room.height as f32;
if grid_x >= room_x && grid_x < room_right && grid_y >= room_y && grid_y < room_bottom {
return Some((room_idx, grid_x - room_x, grid_y - room_y));
}
}
None
}
// Computes at pointer.
pub fn text_at_pointer(
app: &DungeonApp,
pointer_pos: egui::Pos2,
geometry: &GridGeometry,
) -> Option<usize> {
let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?;
let clicked = (grid_x.floor() as usize, grid_y.floor() as usize);
if app.settings.active_level_index >= app.levels.len() {
return None;
}
let layout = &app.levels[app.settings.active_level_index];
layout
.text_labels
.iter()
.position(|label| label.cell == clicked)
}
// Computes at pointer.
pub fn marker_at_pointer(
app: &DungeonApp,
pointer_pos: egui::Pos2,
geometry: &GridGeometry,
) -> Option<HoverMarker> {
let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?;
let clicked = (grid_x.floor() as usize, grid_y.floor() as usize);
if app.settings.active_level_index >= app.levels.len() {
return None;
}
let layout = &app.levels[app.settings.active_level_index];
layout
.start_markers
.iter()
.enumerate()
.find(|(_, marker)| marker_contains_cell(Some(marker), clicked))
.map(|(idx, _)| HoverMarker::Start(idx))
.or_else(|| {
layout
.end_markers
.iter()
.enumerate()
.find(|(_, marker)| marker_contains_cell(Some(marker), clicked))
.map(|(idx, _)| HoverMarker::End(idx))
})
.or_else(|| {
layout
.trap_markers
.iter()
.enumerate()
.find(|(_, marker)| marker_contains_cell(Some(marker), clicked))
.map(|(idx, _)| HoverMarker::Trap(idx))
})
.or_else(|| {
layout
.monster_markers
.iter()
.enumerate()
.find(|(_, marker)| marker_contains_cell(Some(marker), clicked))
.map(|(idx, _)| HoverMarker::Monster(idx))
})
}
// Computes drag at pointer.
pub fn marker_drag_at_pointer(
app: &DungeonApp,
pointer_pos: egui::Pos2,
geometry: &GridGeometry,
) -> Option<MarkerDragState> {
let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?;
if app.settings.active_level_index >= app.levels.len() {
return None;
}
let layout = &app.levels[app.settings.active_level_index];
match marker_at_pointer(app, pointer_pos, geometry)? {
HoverMarker::Start(marker_idx) => {
let marker = layout.start_markers.get(marker_idx)?;
Some(MarkerDragState {
kind: MarkerKind::Start,
marker_idx,
offset_x: grid_x - marker.cell.0 as f32,
offset_y: grid_y - marker.cell.1 as f32,
})
}
HoverMarker::End(marker_idx) => {
let marker = layout.end_markers.get(marker_idx)?;
Some(MarkerDragState {
kind: MarkerKind::End,
marker_idx,
offset_x: grid_x - marker.cell.0 as f32,
offset_y: grid_y - marker.cell.1 as f32,
})
}
HoverMarker::Trap(marker_idx) => {
let marker = layout.trap_markers.get(marker_idx)?;
Some(MarkerDragState {
kind: MarkerKind::Trap,
marker_idx,
offset_x: grid_x - marker.cell.0 as f32,
offset_y: grid_y - marker.cell.1 as f32,
})
}
HoverMarker::Monster(marker_idx) => {
let marker = layout.monster_markers.get(marker_idx)?;
Some(MarkerDragState {
kind: MarkerKind::Monster,
marker_idx,
offset_x: grid_x - marker.cell.0 as f32,
offset_y: grid_y - marker.cell.1 as f32,
})
}
}
}
// Handles stair at pointer.
pub fn stair_at_pointer(
app: &DungeonApp,
pointer_pos: egui::Pos2,
geometry: &GridGeometry,
) -> Option<usize> {
let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?;
let clicked = (grid_x.floor() as usize, grid_y.floor() as usize);
if app.settings.active_level_index >= app.levels.len() {
return None;
}
let layout = &app.levels[app.settings.active_level_index];
layout.stairs.iter().position(|stair| {
clicked.0 >= stair.cell.0
&& clicked.0 < stair.cell.0 + stair.width
&& clicked.1 >= stair.cell.1
&& clicked.1 < stair.cell.1 + stair.height
})
}
// Handles stair drag at pointer.
pub fn stair_drag_at_pointer(
app: &DungeonApp,
pointer_pos: egui::Pos2,
geometry: &GridGeometry,
) -> Option<StaircaseDragState> {
let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?;
if app.settings.active_level_index >= app.levels.len() {
return None;
}
let stair_idx = stair_at_pointer(app, pointer_pos, geometry)?;
let layout = &app.levels[app.settings.active_level_index];
let stair = layout.stairs.get(stair_idx)?;
Some(StaircaseDragState {
stair_idx,
offset_x: grid_x - stair.cell.0 as f32,
offset_y: grid_y - stair.cell.1 as f32,
original_cell: stair.cell,
})
}
// Resizes room at pointer.
pub fn resize_room_at_pointer(
app: &DungeonApp,
pointer_pos: egui::Pos2,
geometry: &GridGeometry,
) -> Option<usize> {
if let Some((room_idx, _, _)) = room_at_pointer(app, pointer_pos, geometry) {
return Some(room_idx);
}
let (grid_x, grid_y) = pointer_to_grid(pointer_pos, geometry)?;
let clicked = (
grid_x.floor().max(0.0) as usize,
grid_y.floor().max(0.0) as usize,
);
if app.settings.active_level_index >= app.levels.len() {
return None;
}
let layout = &app.levels[app.settings.active_level_index];
for (room_idx, room) in layout.rooms.iter().enumerate().rev() {
if resize_hit_cells(room, room_idx, &layout.rooms, geometry.cols, geometry.rows)
.contains(&clicked)
{
return Some(room_idx);
}
}
None
}
// Computes drag at pointer.
pub fn corridor_drag_at_pointer(
app: &DungeonApp,
pointer_pos: egui::Pos2,
geometry: &GridGeometry,
) -> Option<CorridorDragState> {
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,
})
}
+8 -1109
View File
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
/*
* Path rerouting helpers for corridor dragging and room resizing.
*/
use crate::layout;
use std::collections::HashSet;
// Reroutes path through cell.
pub fn reroute_path_through_cell(
original_path: &[(usize, usize)],
original_cell: (usize, usize),
target_cell: (usize, usize),
cols: usize,
rows: usize,
blocked_room_cells_set: &HashSet<(usize, usize)>,
) -> Option<Vec<(usize, usize)>> {
let (&start, &end) = (original_path.first()?, original_path.last()?);
if start == end {
return None;
}
let mut blocked = blocked_room_cells_set.clone();
blocked.insert(original_cell);
blocked.remove(&start);
blocked.remove(&end);
blocked.remove(&target_cell);
let first_leg = crate::layout::shortest_path_cells(start, target_cell, cols, rows, &blocked)?;
let mut blocked_second = blocked.clone();
for &cell in first_leg
.iter()
.skip(1)
.take(first_leg.len().saturating_sub(2))
{
if cell != target_cell && cell != end {
blocked_second.insert(cell);
}
}
let second_leg =
crate::layout::shortest_path_cells(target_cell, end, cols, rows, &blocked_second).or_else(
|| crate::layout::shortest_path_cells(target_cell, end, cols, rows, &blocked),
)?;
let mut full_path = first_leg;
for cell in second_leg.into_iter().skip(1) {
if full_path.last().copied() != Some(cell) {
full_path.push(cell);
}
}
simplify_path_loops(&mut full_path);
if full_path.len() < 2 || !full_path.contains(&target_cell) {
return None;
}
if original_cell != start && original_cell != end && full_path.contains(&original_cell) {
return None;
}
Some(full_path)
}
// Simplifies path loops.
pub fn simplify_path_loops(path: &mut Vec<(usize, usize)>) {
let mut out = Vec::new();
for &cell in path.iter() {
if let Some(pos) = out.iter().position(|&c| c == cell) {
out.truncate(pos + 1);
} else {
out.push(cell);
}
}
*path = out;
}
// Resizes hit cells.
pub fn resize_hit_cells(
room: &layout::Room,
room_idx: usize,
rooms: &[layout::Room],
cols: usize,
rows: usize,
) -> HashSet<(usize, usize)> {
let mut cells = HashSet::new();
let corners = [
(room.x as isize, room.y as isize, -1isize, -1isize),
(
(room.x + room.width - 1) as isize,
room.y as isize,
1isize,
-1isize,
),
(
room.x as isize,
(room.y + room.height - 1) as isize,
-1isize,
1isize,
),
(
(room.x + room.width - 1) as isize,
(room.y + room.height - 1) as isize,
1isize,
1isize,
),
];
for (cx, cy, ox, oy) in corners {
let candidates = [
(cx, cy),
(cx - ox, cy),
(cx, cy - oy),
(cx - ox, cy - oy),
(cx + ox, cy),
(cx, cy + oy),
(cx + ox, cy + oy),
];
for (x, y) in candidates {
if x < 0 || y < 0 || x >= cols as isize || y >= rows as isize {
continue;
}
let cell = (x as usize, y as usize);
let in_other_room = rooms.iter().enumerate().any(|(idx, other_room)| {
idx != room_idx
&& cell.0 >= other_room.x
&& cell.0 < other_room.x + other_room.width
&& cell.1 >= other_room.y
&& cell.1 < other_room.y + other_room.height
});
if !in_other_room {
cells.insert(cell);
}
}
}
cells
}
+194 -1
View File
@@ -9,9 +9,10 @@ use super::{
stair_at_pointer, text_at_pointer,
};
use crate::app::DungeonApp;
use crate::layout::{rects_overlap, rooms_overlap};
use crate::layout::{self, rects_overlap, rooms_overlap};
use crate::ui::AddTool;
use eframe::egui;
use egui::{Color32, Stroke};
// Handles secondary-button resize gestures for resizable layout elements.
pub fn handle_resize(
@@ -325,3 +326,195 @@ pub fn resize_staircase(
s.original_stair.height = new_stair_height;
}
}
// Draws resize overlay.
pub fn draw_resize_overlay(app: &DungeonApp, painter: &egui::Painter, geometry: &GridGeometry) {
if app.settings.active_level_index >= app.levels.len() {
return;
}
let layout = &app.levels[app.settings.active_level_index];
if let Some(state) = &app.resize_state {
match state {
ResizeState::Text(state) => {
if let Some(label) = layout.text_labels.get(state.text_idx) {
let rect = crate::rendering::text_label_rect(geometry, label).expand(3.0);
painter.rect_stroke(
rect,
0.0,
Stroke::new(2.0, Color32::from_gray(180)),
egui::StrokeKind::Middle,
);
}
}
ResizeState::Room(state) => {
if let Some(room) = layout.rooms.get(state.room_idx) {
draw_room_resize_visuals(painter, geometry, room, true);
}
}
ResizeState::Staircase(state) => {
if let Some(stair) = layout.stairs.get(state.stair_idx) {
draw_staircase_resize_visuals(painter, geometry, stair, true);
}
}
}
return;
}
if let Some(text_idx) = app.hover_text_idx {
if let Some(label) = layout.text_labels.get(text_idx) {
let rect = crate::rendering::text_label_rect(geometry, label).expand(3.0);
painter.rect_stroke(
rect,
0.0,
Stroke::new(2.0, Color32::from_gray(180)),
egui::StrokeKind::Middle,
);
}
} else if let Some(room_idx) = app.hover_room_idx {
if let Some(room) = layout.rooms.get(room_idx) {
draw_room_resize_visuals(painter, geometry, room, false);
}
} else if let Some(stair_idx) = app.hover_stair_idx
&& let Some(stair) = layout.stairs.get(stair_idx)
{
draw_staircase_resize_visuals(painter, geometry, stair, false);
}
}
// Draws room resize visuals.
pub fn draw_room_resize_visuals(
painter: &egui::Painter,
geometry: &GridGeometry,
room: &layout::Room,
show_handles: bool,
) {
let color = Color32::from_gray(170);
let stroke = Stroke::new(1.5, color);
let left = geometry.rect.left() + room.x as f32 * geometry.cell_size;
let top = geometry.rect.top() + room.y as f32 * geometry.cell_size;
let right = left + room.width as f32 * geometry.cell_size;
let bottom = top + room.height as f32 * geometry.cell_size;
let tl = egui::pos2(left, top);
let tr = egui::pos2(right, top);
let br = egui::pos2(right, bottom);
let bl = egui::pos2(left, bottom);
crate::rendering::draw_dashed_line(painter, tl, tr, stroke, 6.0, 6.0);
crate::rendering::draw_dashed_line(painter, tr, br, stroke, 6.0, 6.0);
crate::rendering::draw_dashed_line(painter, br, bl, stroke, 6.0, 6.0);
crate::rendering::draw_dashed_line(painter, bl, tl, stroke, 6.0, 6.0);
let font = egui::FontId::proportional(12.0);
let center_top = egui::pos2((left + right) * 0.5, top - 10.0);
let center_left = egui::pos2(left - 12.0, (top + bottom) * 0.5);
painter.text(
center_top,
egui::Align2::CENTER_CENTER,
"<->",
font.clone(),
color,
);
painter.text(center_left, egui::Align2::CENTER_CENTER, "^v", font, color);
if show_handles {
draw_resize_handle(painter, tl, ResizeCorner::TopLeft, color);
draw_resize_handle(painter, tr, ResizeCorner::TopRight, color);
draw_resize_handle(painter, br, ResizeCorner::BottomRight, color);
draw_resize_handle(painter, bl, ResizeCorner::BottomLeft, color);
}
}
// Draws staircase resize visuals.
pub fn draw_staircase_resize_visuals(
painter: &egui::Painter,
geometry: &GridGeometry,
stair: &layout::Staircase,
show_handles: bool,
) {
let color = Color32::from_gray(170);
let stroke = Stroke::new(1.5, color);
let left = geometry.rect.left() + stair.cell.0 as f32 * geometry.cell_size;
let top = geometry.rect.top() + stair.cell.1 as f32 * geometry.cell_size;
let right = left + stair.width as f32 * geometry.cell_size;
let bottom = top + stair.height as f32 * geometry.cell_size;
let tl = egui::pos2(left, top);
let tr = egui::pos2(right, top);
let br = egui::pos2(right, bottom);
let bl = egui::pos2(left, bottom);
crate::rendering::draw_dashed_line(painter, tl, tr, stroke, 6.0, 6.0);
crate::rendering::draw_dashed_line(painter, tr, br, stroke, 6.0, 6.0);
crate::rendering::draw_dashed_line(painter, br, bl, stroke, 6.0, 6.0);
crate::rendering::draw_dashed_line(painter, bl, tl, stroke, 6.0, 6.0);
if show_handles {
draw_resize_handle(painter, tl, ResizeCorner::TopLeft, color);
draw_resize_handle(painter, tr, ResizeCorner::TopRight, color);
draw_resize_handle(painter, br, ResizeCorner::BottomRight, color);
draw_resize_handle(painter, bl, ResizeCorner::BottomLeft, color);
}
}
// Draws resize handle.
pub fn draw_resize_handle(
painter: &egui::Painter,
corner: egui::Pos2,
which: ResizeCorner,
color: Color32,
) {
let size = 10.0;
let half = size * 0.5;
let rect = egui::Rect::from_center_size(corner, egui::vec2(size, size));
painter.rect_filled(rect, 2.0, Color32::from_gray(30));
painter.rect_stroke(rect, 2.0, Stroke::new(1.0, color), egui::StrokeKind::Middle);
let center = corner;
let stroke = Stroke::new(1.0, color);
match which {
ResizeCorner::TopLeft => {
painter.line_segment(
[center, egui::pos2(center.x - half + 2.0, center.y)],
stroke,
);
painter.line_segment(
[center, egui::pos2(center.x, center.y - half + 2.0)],
stroke,
);
}
ResizeCorner::TopRight => {
painter.line_segment(
[center, egui::pos2(center.x + half - 2.0, center.y)],
stroke,
);
painter.line_segment(
[center, egui::pos2(center.x, center.y - half + 2.0)],
stroke,
);
}
ResizeCorner::BottomLeft => {
painter.line_segment(
[center, egui::pos2(center.x - half + 2.0, center.y)],
stroke,
);
painter.line_segment(
[center, egui::pos2(center.x, center.y + half - 2.0)],
stroke,
);
}
ResizeCorner::BottomRight => {
painter.line_segment(
[center, egui::pos2(center.x + half - 2.0, center.y)],
stroke,
);
painter.line_segment(
[center, egui::pos2(center.x, center.y + half - 2.0)],
stroke,
);
}
}
}
+4 -185
View File
@@ -1,197 +1,16 @@
/*
* Main generation module.
* Orchestrates the procedural generation process, coordinating
* room placement, connection graph building, and passage/marker population.
* Procedural generation module facade.
* Re-exports room placement, passage, marker, and orchestration helpers.
*/
use super::types::{Corridor, DoorSettings, DungeonLayout, Room, WindowSettings};
use super::utils::{SimpleRng, blocked_room_cells, noisy_path, shortest_path_cells};
use crate::seed;
pub mod connections;
pub mod markers;
pub mod orchestration;
pub mod passages;
pub mod room_placement;
pub use connections::*;
pub use markers::*;
pub use orchestration::*;
pub use passages::*;
pub use room_placement::*;
// Generates a complete dungeon layout from generation settings.
#[allow(clippy::too_many_arguments)]
pub fn generate_layout(
cols: usize,
rows: usize,
target_room_count: usize,
seed: u64,
min_room_size: usize,
max_room_size: usize,
square_rooms_only: bool,
min_corridor_width: usize,
max_corridor_width: usize,
corridor_randomness_percent: usize,
dead_end_room_percent: usize,
pack_rooms_without_corridors: bool,
door_settings: DoorSettings,
window_settings: WindowSettings,
) -> DungeonLayout {
let layout_salt = ((cols as u64) << 48)
^ ((rows as u64) << 32)
^ ((target_room_count as u64) << 16)
^ (min_room_size as u64)
^ ((max_room_size as u64) << 8)
^ ((min_corridor_width as u64) << 24)
^ ((max_corridor_width as u64) << 28)
^ ((corridor_randomness_percent as u64) << 56)
^ ((dead_end_room_percent as u64) << 40);
let base_seed = seed::derive_seed(seed, layout_salt);
let mut room_rng = SimpleRng::new(seed::derive_seed(base_seed, 1));
let mut graph_rng = SimpleRng::new(seed::derive_seed(base_seed, 2));
let mut path_rng = SimpleRng::new(seed::derive_seed(base_seed, 3));
let mut corridor_rng = SimpleRng::new(seed::derive_seed(base_seed, 4));
let mut rooms = Vec::new();
let corridors = Vec::new();
if cols < 2 || rows < 2 || target_room_count == 0 {
return DungeonLayout::empty(pack_rooms_without_corridors);
}
let mut min_size = min_room_size.max(2);
let mut max_size = max_room_size.max(min_size);
if square_rooms_only {
let hard_max = cols.min(rows);
min_size = min_size.min(hard_max);
max_size = max_size.min(hard_max);
} else {
min_size = min_size.min(cols.min(rows));
max_size = max_size.min(cols.max(rows));
}
if min_size == 0 || max_size < min_size {
return DungeonLayout::empty(pack_rooms_without_corridors);
}
let room_sizes = generate_room_sizes(
target_room_count,
cols,
rows,
min_size,
max_size,
square_rooms_only,
&mut room_rng,
);
let room_count = room_sizes.len();
let centers = random_centers(room_count, cols, rows, &mut graph_rng);
let randomness = (corridor_randomness_percent.min(100) as f32) / 100.0;
let target_dead_end_rooms = ((room_count * dead_end_room_percent.min(50)) + 50) / 100;
let room_edges =
build_room_connection_edges(&centers, randomness, target_dead_end_rooms, &mut graph_rng);
if pack_rooms_without_corridors {
rooms = place_packed_rooms(&room_sizes, &room_edges, cols, rows, &mut room_rng);
let mut layout = DungeonLayout::from_generated_parts(rooms, corridors, true);
apply_doors(&mut layout, seed, door_settings, cols, rows);
apply_windows(&mut layout, seed, window_settings, cols, rows);
return layout;
}
let max_attempts = target_room_count.saturating_mul(40).max(50);
for _ in 0..max_attempts {
if rooms.len() >= target_room_count {
break;
}
let (width, height) = if square_rooms_only {
let side = room_rng.range_inclusive(min_size, max_size.min(cols.min(rows)));
(side, side)
} else {
let width_max = max_size.min(cols);
let height_max = max_size.min(rows);
if min_size > width_max || min_size > height_max {
continue;
}
(
room_rng.range_inclusive(min_size, width_max),
room_rng.range_inclusive(min_size, height_max),
)
};
if width > cols || height > rows {
continue;
}
let x = room_rng.range_inclusive(0, cols - width);
let y = room_rng.range_inclusive(0, rows - height);
let candidate = Room {
x,
y,
width,
height,
};
if rooms
.iter()
.all(|existing| !crate::layout::utils::overlaps_with_padding(&candidate, existing, 1))
{
rooms.push(candidate);
}
}
if rooms.len() < 2 {
return DungeonLayout::from_generated_parts(rooms, corridors, false);
}
let mut min_width = min_corridor_width.max(1);
let max_grid_width = cols.max(1).min(rows.max(1));
let max_width = max_corridor_width.max(min_width).min(max_grid_width);
min_width = min_width.min(max_width);
let centers: Vec<(usize, usize)> = rooms.iter().map(Room::center_cell).collect();
let target_dead_end_rooms = ((rooms.len() * dead_end_room_percent.min(50)) + 50) / 100;
let room_edges =
build_room_connection_edges(&centers, randomness, target_dead_end_rooms, &mut graph_rng);
let mut corridors = Vec::new();
let mut next_corridor_id = 1u64;
for (start_room_id, end_room_id) in room_edges {
let start = centers[start_room_id];
let end = centers[end_room_id];
let blocked = blocked_room_cells(&rooms, &[start_room_id, end_room_id]);
let path = if randomness <= 0.001 {
shortest_path_cells(start, end, cols, rows, &blocked)
} else {
Some(noisy_path(
start,
end,
cols,
rows,
randomness,
&blocked,
&mut path_rng,
))
}
.or_else(|| shortest_path_cells(start, end, cols, rows, &blocked));
if let Some(path) = path
&& path.len() >= 2
{
corridors.push(Corridor {
id: next_corridor_id,
start_room_id,
end_room_id,
path,
width: corridor_rng.range_inclusive(min_width, max_width),
});
next_corridor_id = next_corridor_id.wrapping_add(1);
}
}
let mut layout = DungeonLayout::from_generated_parts(rooms, corridors, false);
apply_doors(&mut layout, seed, door_settings, cols, rows);
apply_windows(&mut layout, seed, window_settings, cols, rows);
layout
}
+191
View File
@@ -0,0 +1,191 @@
/*
* Top-level procedural generation orchestration.
* Coordinates room placement, corridor routing, doors, and windows.
*/
use super::super::types::{Corridor, DoorSettings, DungeonLayout, Room, WindowSettings};
use super::super::utils::{SimpleRng, blocked_room_cells, noisy_path, shortest_path_cells};
use crate::seed;
use super::{
apply_doors, apply_windows, build_room_connection_edges, generate_room_sizes,
place_packed_rooms, random_centers,
};
// Generates a complete dungeon layout from generation settings.
#[allow(clippy::too_many_arguments)]
pub fn generate_layout(
cols: usize,
rows: usize,
target_room_count: usize,
seed: u64,
min_room_size: usize,
max_room_size: usize,
square_rooms_only: bool,
min_corridor_width: usize,
max_corridor_width: usize,
corridor_randomness_percent: usize,
dead_end_room_percent: usize,
pack_rooms_without_corridors: bool,
door_settings: DoorSettings,
window_settings: WindowSettings,
) -> DungeonLayout {
let layout_salt = ((cols as u64) << 48)
^ ((rows as u64) << 32)
^ ((target_room_count as u64) << 16)
^ (min_room_size as u64)
^ ((max_room_size as u64) << 8)
^ ((min_corridor_width as u64) << 24)
^ ((max_corridor_width as u64) << 28)
^ ((corridor_randomness_percent as u64) << 56)
^ ((dead_end_room_percent as u64) << 40);
let base_seed = seed::derive_seed(seed, layout_salt);
let mut room_rng = SimpleRng::new(seed::derive_seed(base_seed, 1));
let mut graph_rng = SimpleRng::new(seed::derive_seed(base_seed, 2));
let mut path_rng = SimpleRng::new(seed::derive_seed(base_seed, 3));
let mut corridor_rng = SimpleRng::new(seed::derive_seed(base_seed, 4));
let mut rooms = Vec::new();
let corridors = Vec::new();
if cols < 2 || rows < 2 || target_room_count == 0 {
return DungeonLayout::empty(pack_rooms_without_corridors);
}
let mut min_size = min_room_size.max(2);
let mut max_size = max_room_size.max(min_size);
if square_rooms_only {
let hard_max = cols.min(rows);
min_size = min_size.min(hard_max);
max_size = max_size.min(hard_max);
} else {
min_size = min_size.min(cols.min(rows));
max_size = max_size.min(cols.max(rows));
}
if min_size == 0 || max_size < min_size {
return DungeonLayout::empty(pack_rooms_without_corridors);
}
let room_sizes = generate_room_sizes(
target_room_count,
cols,
rows,
min_size,
max_size,
square_rooms_only,
&mut room_rng,
);
let room_count = room_sizes.len();
let centers = random_centers(room_count, cols, rows, &mut graph_rng);
let randomness = (corridor_randomness_percent.min(100) as f32) / 100.0;
let target_dead_end_rooms = ((room_count * dead_end_room_percent.min(50)) + 50) / 100;
let room_edges =
build_room_connection_edges(&centers, randomness, target_dead_end_rooms, &mut graph_rng);
if pack_rooms_without_corridors {
rooms = place_packed_rooms(&room_sizes, &room_edges, cols, rows, &mut room_rng);
let mut layout = DungeonLayout::from_generated_parts(rooms, corridors, true);
apply_doors(&mut layout, seed, door_settings, cols, rows);
apply_windows(&mut layout, seed, window_settings, cols, rows);
return layout;
}
let max_attempts = target_room_count.saturating_mul(40).max(50);
for _ in 0..max_attempts {
if rooms.len() >= target_room_count {
break;
}
let (width, height) = if square_rooms_only {
let side = room_rng.range_inclusive(min_size, max_size.min(cols.min(rows)));
(side, side)
} else {
let width_max = max_size.min(cols);
let height_max = max_size.min(rows);
if min_size > width_max || min_size > height_max {
continue;
}
(
room_rng.range_inclusive(min_size, width_max),
room_rng.range_inclusive(min_size, height_max),
)
};
if width > cols || height > rows {
continue;
}
let x = room_rng.range_inclusive(0, cols - width);
let y = room_rng.range_inclusive(0, rows - height);
let candidate = Room {
x,
y,
width,
height,
};
if rooms
.iter()
.all(|existing| !crate::layout::utils::overlaps_with_padding(&candidate, existing, 1))
{
rooms.push(candidate);
}
}
if rooms.len() < 2 {
return DungeonLayout::from_generated_parts(rooms, corridors, false);
}
let mut min_width = min_corridor_width.max(1);
let max_grid_width = cols.max(1).min(rows.max(1));
let max_width = max_corridor_width.max(min_width).min(max_grid_width);
min_width = min_width.min(max_width);
let centers: Vec<(usize, usize)> = rooms.iter().map(Room::center_cell).collect();
let target_dead_end_rooms = ((rooms.len() * dead_end_room_percent.min(50)) + 50) / 100;
let room_edges =
build_room_connection_edges(&centers, randomness, target_dead_end_rooms, &mut graph_rng);
let mut corridors = Vec::new();
let mut next_corridor_id = 1u64;
for (start_room_id, end_room_id) in room_edges {
let start = centers[start_room_id];
let end = centers[end_room_id];
let blocked = blocked_room_cells(&rooms, &[start_room_id, end_room_id]);
let path = if randomness <= 0.001 {
shortest_path_cells(start, end, cols, rows, &blocked)
} else {
Some(noisy_path(
start,
end,
cols,
rows,
randomness,
&blocked,
&mut path_rng,
))
}
.or_else(|| shortest_path_cells(start, end, cols, rows, &blocked));
if let Some(path) = path
&& path.len() >= 2
{
corridors.push(Corridor {
id: next_corridor_id,
start_room_id,
end_room_id,
path,
width: corridor_rng.range_inclusive(min_width, max_width),
});
next_corridor_id = next_corridor_id.wrapping_add(1);
}
}
let mut layout = DungeonLayout::from_generated_parts(rooms, corridors, false);
apply_doors(&mut layout, seed, door_settings, cols, rows);
apply_windows(&mut layout, seed, window_settings, cols, rows);
layout
}
+6 -447
View File
@@ -1,453 +1,12 @@
/*
* The primary UI module. Defines the UiSettings structure and
* manages the side panel layout, legend panel, and level selection tabs.
* Serves as the entry point for all UI-related rendering.
* UI module facade.
* Re-exports UI settings, panel entry points, tabs, and widgets.
*/
use eframe::egui;
use serde::{Deserialize, Serialize};
pub mod panels;
pub mod tabs;
pub mod types;
pub mod widgets;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Tab {
Generate,
Layout,
StartAndEnd,
MonstersAndTraps,
Add,
}
impl Default for Tab {
// Provides default settings for this type.
fn default() -> Self {
Self::Generate
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExportFormat {
Png,
Jpeg,
Webp,
Svg,
Folder,
}
impl Default for ExportFormat {
// Provides default settings for this type.
fn default() -> Self {
Self::Png
}
}
impl ExportFormat {
// Returns the file extension for this format.
pub fn extension(self) -> &'static str {
match self {
ExportFormat::Png => "png",
ExportFormat::Jpeg => "jpeg",
ExportFormat::Webp => "webp",
ExportFormat::Svg => "svg",
ExportFormat::Folder => "folder",
}
}
// Returns the user-facing label for this option.
pub fn label(self) -> &'static str {
match self {
ExportFormat::Png => ".png",
ExportFormat::Jpeg => ".jpeg",
ExportFormat::Webp => ".webp",
ExportFormat::Svg => ".svg",
ExportFormat::Folder => "Folder (Composite)",
}
}
// Reports whether this format supports resolution scaling.
pub fn supports_resolution(self) -> bool {
!matches!(self, ExportFormat::Svg)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MaskFormat {
Png,
Jpeg,
Webp,
}
impl Default for MaskFormat {
// Provides default settings for this type.
fn default() -> Self {
Self::Png
}
}
impl MaskFormat {
// Returns the file extension for this format.
pub fn extension(self) -> &'static str {
match self {
MaskFormat::Png => "png",
MaskFormat::Jpeg => "jpeg",
MaskFormat::Webp => "webp",
}
}
// Returns the user-facing label for this option.
pub fn label(self) -> &'static str {
match self {
MaskFormat::Png => ".png",
MaskFormat::Jpeg => ".jpeg",
MaskFormat::Webp => ".webp",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct UiSettings {
pub seed: u64,
pub cols: usize,
pub rows: usize,
pub room_count: usize,
pub min_room_size: usize,
pub max_room_size: usize,
pub square_rooms_only: bool,
pub min_corridor_width: usize,
pub max_corridor_width: usize,
pub corridor_randomness: usize,
pub dead_end_rooms_percent: usize,
pub pack_rooms_without_corridors: bool,
pub door_frequency_percent: usize,
pub room_hallway_door_percent: usize,
pub locked_door_percent: usize,
pub secret_door_percent: usize,
pub allow_middle_corridor_doors: bool,
pub windows_enabled: bool,
pub min_window_width: usize,
pub max_window_width: usize,
pub window_frequency_percent: usize,
pub room_hallway_window_percent: usize,
pub allow_internal_windows: bool,
pub colorblind_mode: bool,
pub composition_mode: bool,
pub export_format: ExportFormat,
pub mask_format: MaskFormat,
pub export_width: u32,
pub export_height: u32,
pub allow_export_aspect_change: bool,
pub export_show_grid: bool,
pub last_export_path: Option<String>,
pub add_tool: AddTool,
pub add_text_value: String,
pub add_text_font_size: u16,
pub add_room_width: usize,
pub add_room_height: usize,
pub add_corridor_width: usize,
pub add_stair_width: usize,
pub add_stair_height: usize,
pub add_marker_size: usize,
pub min_start_marker_size: usize,
pub max_start_marker_size: usize,
pub min_end_marker_size: usize,
pub max_end_marker_size: usize,
pub min_start_marker_count: usize,
pub max_start_marker_count: usize,
pub min_end_marker_count: usize,
pub max_end_marker_count: usize,
pub min_levels: usize,
pub max_levels: usize,
pub active_level_index: usize,
pub export_level_index: usize,
pub trap_frequency_percent: usize,
pub min_traps_per_area: usize,
pub max_traps_per_area: usize,
pub monster_frequency_percent: usize,
pub min_monsters_per_area: usize,
pub max_monsters_per_area: usize,
pub min_stair_width: usize,
pub max_stair_width: usize,
pub min_stair_height: usize,
pub max_stair_height: usize,
pub min_stairs_per_level: usize,
pub max_stairs_per_level: usize,
pub sync_stairs_across_levels: bool,
pub active_tab: Tab,
}
impl Default for UiSettings {
// Provides default settings for this type.
fn default() -> Self {
UiSettings {
seed: 0,
cols: 32,
rows: 32,
room_count: 8,
min_room_size: 3,
max_room_size: 8,
square_rooms_only: true,
min_corridor_width: 1,
max_corridor_width: 3,
corridor_randomness: 50,
dead_end_rooms_percent: 20,
pack_rooms_without_corridors: false,
door_frequency_percent: 50,
room_hallway_door_percent: 20,
locked_door_percent: 10,
secret_door_percent: 5,
allow_middle_corridor_doors: false,
windows_enabled: false,
min_window_width: 2,
max_window_width: 4,
window_frequency_percent: 30,
room_hallway_window_percent: 10,
allow_internal_windows: false,
colorblind_mode: false,
composition_mode: false,
export_format: ExportFormat::Png,
mask_format: MaskFormat::Png,
export_width: 0,
export_height: 0,
allow_export_aspect_change: true,
export_show_grid: false,
last_export_path: None,
add_tool: AddTool::None,
add_text_value: String::from("Text"),
add_text_font_size: 18,
add_room_width: 5,
add_room_height: 5,
add_corridor_width: 2,
add_stair_width: 2,
add_stair_height: 2,
add_marker_size: 1,
min_start_marker_size: 1,
max_start_marker_size: 3,
min_end_marker_size: 1,
max_end_marker_size: 3,
min_start_marker_count: 1,
max_start_marker_count: 3,
min_end_marker_count: 1,
max_end_marker_count: 3,
min_levels: 1,
max_levels: 1,
active_level_index: 0,
trap_frequency_percent: 30,
min_traps_per_area: 1,
max_traps_per_area: 3,
monster_frequency_percent: 30,
min_monsters_per_area: 1,
max_monsters_per_area: 3,
min_stair_width: 2,
max_stair_width: 3,
min_stair_height: 2,
max_stair_height: 3,
min_stairs_per_level: 1,
max_stairs_per_level: 2,
sync_stairs_across_levels: false,
export_level_index: 0,
active_tab: Tab::Generate,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AddTool {
None,
Room,
Corridor,
Archway,
Door,
LockedDoor,
SecretDoor,
Text,
StartMarker,
EndMarker,
TrapMarker,
MonsterMarker,
Staircase,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SidePanelResult {
pub settings_changed: bool,
pub reset_clicked: bool,
pub clear_clicked: bool,
pub export_clicked: bool,
pub save_clicked: bool,
pub load_clicked: bool,
pub new_level_clicked: bool,
}
// Draws side panel.
pub fn draw_side_panel(
ctx: &egui::Context,
settings: &mut UiSettings,
export_progress: Option<(usize, usize)>,
num_levels: usize,
) -> SidePanelResult {
let mut result = SidePanelResult::default();
egui::SidePanel::left("options_panel")
.resizable(true)
.min_width(180.0)
.default_width(280.0)
.show_separator_line(true)
.show(ctx, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
ui.horizontal(|ui| {
if ui
.selectable_label(settings.active_tab == Tab::Generate, "Generate")
.clicked()
{
settings.active_tab = Tab::Generate;
}
if ui
.selectable_label(settings.active_tab == Tab::Layout, "Layout")
.clicked()
{
settings.active_tab = Tab::Layout;
}
if ui
.selectable_label(settings.active_tab == Tab::StartAndEnd, "Start & End")
.clicked()
{
settings.active_tab = Tab::StartAndEnd;
}
if ui
.selectable_label(
settings.active_tab == Tab::MonstersAndTraps,
"Monsters/Traps",
)
.clicked()
{
settings.active_tab = Tab::MonstersAndTraps;
}
if ui
.selectable_label(settings.active_tab == Tab::Add, "Add")
.clicked()
{
settings.active_tab = Tab::Add;
}
});
ui.separator();
match settings.active_tab {
Tab::Generate => tabs::draw_generate_tab(
ui,
settings,
&mut result,
export_progress,
num_levels,
),
Tab::Layout => tabs::draw_layout_tab(ui, settings, &mut result),
Tab::StartAndEnd => tabs::draw_start_and_end_tab(ui, settings, &mut result),
Tab::MonstersAndTraps => {
tabs::draw_monsters_and_traps_tab(ui, settings, &mut result)
}
Tab::Add => tabs::draw_add_tab(ui, settings, &mut result),
}
});
});
result
}
// Draws legend panel.
pub fn draw_legend_panel(ctx: &egui::Context, settings: &mut UiSettings) -> bool {
let mut composition_mode_changed = false;
use egui::Color32;
use widgets::LegendStyle;
egui::SidePanel::right("legend_panel")
.resizable(false)
.min_width(180.0)
.default_width(200.0)
.show_separator_line(true)
.show(ctx, |ui| {
ui.heading("Modes");
ui.add_space(6.0);
ui.checkbox(&mut settings.colorblind_mode, "Colorblind Mode");
composition_mode_changed = ui
.checkbox(&mut settings.composition_mode, "Composition Mode")
.changed();
ui.add_space(10.0);
ui.separator();
ui.add_space(8.0);
ui.heading("Legend");
ui.add_space(8.0);
if settings.colorblind_mode {
widgets::draw_legend_entry_colorblind(ui, "Rooms", LegendStyle::Crosshatch);
widgets::draw_legend_entry_colorblind(ui, "Corridors", LegendStyle::Dots);
widgets::draw_legend_entry_colorblind(ui, "Walls", LegendStyle::SolidLine);
widgets::draw_legend_entry_colorblind(ui, "Doors", LegendStyle::LongDash);
widgets::draw_legend_entry_colorblind(ui, "Locked Doors", LegendStyle::ShortDash);
widgets::draw_legend_entry_colorblind(
ui,
"Secret Doors",
LegendStyle::DashDotDotLine,
);
widgets::draw_legend_entry_colorblind(ui, "Archways", LegendStyle::DottedLine);
widgets::draw_legend_entry_colorblind(ui, "Windows", LegendStyle::DashDotLine);
widgets::draw_legend_entry_colorblind(
ui,
"Stairs",
LegendStyle::LabelChar('\u{1F5CF}'),
);
widgets::draw_legend_entry_colorblind(ui, "Start Marker", LegendStyle::Label("S"));
widgets::draw_legend_entry_colorblind(ui, "End Marker", LegendStyle::Label("E"));
widgets::draw_legend_entry_colorblind(ui, "Trap", LegendStyle::Label("T"));
widgets::draw_legend_entry_colorblind(ui, "Monster", LegendStyle::Label("M"));
} else {
widgets::draw_legend_entry(ui, Color32::from_rgb(70, 120, 160), "Rooms");
widgets::draw_legend_entry(ui, Color32::from_rgb(210, 190, 120), "Corridors");
widgets::draw_legend_entry(ui, Color32::BLACK, "Walls");
widgets::draw_legend_entry(ui, Color32::from_rgb(80, 200, 120), "Doors");
widgets::draw_legend_entry(ui, Color32::from_rgb(220, 70, 70), "Locked Doors");
widgets::draw_legend_entry(ui, Color32::from_rgb(170, 80, 170), "Secret Doors");
widgets::draw_legend_entry(ui, Color32::from_rgb(230, 140, 60), "Archways");
widgets::draw_legend_entry(ui, Color32::from_rgb(70, 130, 220), "Windows");
widgets::draw_legend_entry(ui, Color32::from_rgb(200, 150, 50), "Stairs");
widgets::draw_legend_entry(ui, Color32::from_rgb(60, 220, 200), "Start Marker");
widgets::draw_legend_entry(ui, Color32::from_rgb(240, 90, 90), "End Marker");
widgets::draw_legend_entry(ui, Color32::from_rgb(150, 80, 230), "Trap");
widgets::draw_legend_entry(ui, Color32::from_rgb(200, 50, 50), "Monster");
}
ui.add_space(10.0);
ui.separator();
ui.add_space(8.0);
ui.heading("Keybinds");
ui.add_space(6.0);
ui.label("• Delete / Backspace: Remove hovered room, corridor, door, text, or marker");
ui.label("• Ctrl+Z: Undo");
ui.label("• Ctrl+Y: Redo");
ui.label("• Left click + drag: Move hovered room");
ui.label("• Right click: Cancel add tool");
ui.label("• Right click + drag: Resize hovered room");
});
composition_mode_changed
}
// Draws level tabs.
pub fn draw_level_tabs(
ui: &mut egui::Ui,
level_count: usize,
active_level_index: &mut usize,
) -> Option<usize> {
let mut hovered_level_idx = None;
ui.horizontal(|ui| {
for level_idx in 0..level_count {
let label = format!("Level {}", level_idx + 1);
let is_active = *active_level_index == level_idx;
let response = ui.selectable_label(is_active, label);
if response.hovered() {
hovered_level_idx = Some(level_idx);
}
if response.clicked() {
*active_level_index = level_idx;
}
}
});
hovered_level_idx
}
pub use panels::*;
pub use types::*;
+180
View File
@@ -0,0 +1,180 @@
/*
* UI panel composition.
* Draws the side panel, legend panel, and level tab strip.
*/
use super::{SidePanelResult, Tab, UiSettings, tabs, widgets};
use eframe::egui;
// Draws side panel.
pub fn draw_side_panel(
ctx: &egui::Context,
settings: &mut UiSettings,
export_progress: Option<(usize, usize)>,
num_levels: usize,
) -> SidePanelResult {
let mut result = SidePanelResult::default();
egui::SidePanel::left("options_panel")
.resizable(true)
.min_width(180.0)
.default_width(280.0)
.show_separator_line(true)
.show(ctx, |ui| {
egui::ScrollArea::vertical().show(ui, |ui| {
ui.horizontal(|ui| {
if ui
.selectable_label(settings.active_tab == Tab::Generate, "Generate")
.clicked()
{
settings.active_tab = Tab::Generate;
}
if ui
.selectable_label(settings.active_tab == Tab::Layout, "Layout")
.clicked()
{
settings.active_tab = Tab::Layout;
}
if ui
.selectable_label(settings.active_tab == Tab::StartAndEnd, "Start & End")
.clicked()
{
settings.active_tab = Tab::StartAndEnd;
}
if ui
.selectable_label(
settings.active_tab == Tab::MonstersAndTraps,
"Monsters/Traps",
)
.clicked()
{
settings.active_tab = Tab::MonstersAndTraps;
}
if ui
.selectable_label(settings.active_tab == Tab::Add, "Add")
.clicked()
{
settings.active_tab = Tab::Add;
}
});
ui.separator();
match settings.active_tab {
Tab::Generate => tabs::draw_generate_tab(
ui,
settings,
&mut result,
export_progress,
num_levels,
),
Tab::Layout => tabs::draw_layout_tab(ui, settings, &mut result),
Tab::StartAndEnd => tabs::draw_start_and_end_tab(ui, settings, &mut result),
Tab::MonstersAndTraps => {
tabs::draw_monsters_and_traps_tab(ui, settings, &mut result)
}
Tab::Add => tabs::draw_add_tab(ui, settings, &mut result),
}
});
});
result
}
// Draws legend panel.
pub fn draw_legend_panel(ctx: &egui::Context, settings: &mut UiSettings) -> bool {
let mut composition_mode_changed = false;
use egui::Color32;
use widgets::LegendStyle;
egui::SidePanel::right("legend_panel")
.resizable(false)
.min_width(180.0)
.default_width(200.0)
.show_separator_line(true)
.show(ctx, |ui| {
ui.heading("Modes");
ui.add_space(6.0);
ui.checkbox(&mut settings.colorblind_mode, "Colorblind Mode");
composition_mode_changed = ui
.checkbox(&mut settings.composition_mode, "Composition Mode")
.changed();
ui.add_space(10.0);
ui.separator();
ui.add_space(8.0);
ui.heading("Legend");
ui.add_space(8.0);
if settings.colorblind_mode {
widgets::draw_legend_entry_colorblind(ui, "Rooms", LegendStyle::Crosshatch);
widgets::draw_legend_entry_colorblind(ui, "Corridors", LegendStyle::Dots);
widgets::draw_legend_entry_colorblind(ui, "Walls", LegendStyle::SolidLine);
widgets::draw_legend_entry_colorblind(ui, "Doors", LegendStyle::LongDash);
widgets::draw_legend_entry_colorblind(ui, "Locked Doors", LegendStyle::ShortDash);
widgets::draw_legend_entry_colorblind(
ui,
"Secret Doors",
LegendStyle::DashDotDotLine,
);
widgets::draw_legend_entry_colorblind(ui, "Archways", LegendStyle::DottedLine);
widgets::draw_legend_entry_colorblind(ui, "Windows", LegendStyle::DashDotLine);
widgets::draw_legend_entry_colorblind(
ui,
"Stairs",
LegendStyle::LabelChar('\u{1F5CF}'),
);
widgets::draw_legend_entry_colorblind(ui, "Start Marker", LegendStyle::Label("S"));
widgets::draw_legend_entry_colorblind(ui, "End Marker", LegendStyle::Label("E"));
widgets::draw_legend_entry_colorblind(ui, "Trap", LegendStyle::Label("T"));
widgets::draw_legend_entry_colorblind(ui, "Monster", LegendStyle::Label("M"));
} else {
widgets::draw_legend_entry(ui, Color32::from_rgb(70, 120, 160), "Rooms");
widgets::draw_legend_entry(ui, Color32::from_rgb(210, 190, 120), "Corridors");
widgets::draw_legend_entry(ui, Color32::BLACK, "Walls");
widgets::draw_legend_entry(ui, Color32::from_rgb(80, 200, 120), "Doors");
widgets::draw_legend_entry(ui, Color32::from_rgb(220, 70, 70), "Locked Doors");
widgets::draw_legend_entry(ui, Color32::from_rgb(170, 80, 170), "Secret Doors");
widgets::draw_legend_entry(ui, Color32::from_rgb(230, 140, 60), "Archways");
widgets::draw_legend_entry(ui, Color32::from_rgb(70, 130, 220), "Windows");
widgets::draw_legend_entry(ui, Color32::from_rgb(200, 150, 50), "Stairs");
widgets::draw_legend_entry(ui, Color32::from_rgb(60, 220, 200), "Start Marker");
widgets::draw_legend_entry(ui, Color32::from_rgb(240, 90, 90), "End Marker");
widgets::draw_legend_entry(ui, Color32::from_rgb(150, 80, 230), "Trap");
widgets::draw_legend_entry(ui, Color32::from_rgb(200, 50, 50), "Monster");
}
ui.add_space(10.0);
ui.separator();
ui.add_space(8.0);
ui.heading("Keybinds");
ui.add_space(6.0);
ui.label("• Delete / Backspace: Remove hovered room, corridor, door, text, or marker");
ui.label("• Ctrl+Z: Undo");
ui.label("• Ctrl+Y: Redo");
ui.label("• Left click + drag: Move hovered room");
ui.label("• Right click: Cancel add tool");
ui.label("• Right click + drag: Resize hovered room");
});
composition_mode_changed
}
// Draws level tabs.
pub fn draw_level_tabs(
ui: &mut egui::Ui,
level_count: usize,
active_level_index: &mut usize,
) -> Option<usize> {
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
}
+276
View File
@@ -0,0 +1,276 @@
/*
* UI settings and option types.
* Defines serializable configuration used by the app and UI controls.
*/
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Tab {
Generate,
Layout,
StartAndEnd,
MonstersAndTraps,
Add,
}
impl Default for Tab {
// Provides default settings for this type.
fn default() -> Self {
Self::Generate
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExportFormat {
Png,
Jpeg,
Webp,
Svg,
Folder,
}
impl Default for ExportFormat {
// Provides default settings for this type.
fn default() -> Self {
Self::Png
}
}
impl ExportFormat {
// Returns the file extension for this format.
pub fn extension(self) -> &'static str {
match self {
ExportFormat::Png => "png",
ExportFormat::Jpeg => "jpeg",
ExportFormat::Webp => "webp",
ExportFormat::Svg => "svg",
ExportFormat::Folder => "folder",
}
}
// Returns the user-facing label for this option.
pub fn label(self) -> &'static str {
match self {
ExportFormat::Png => ".png",
ExportFormat::Jpeg => ".jpeg",
ExportFormat::Webp => ".webp",
ExportFormat::Svg => ".svg",
ExportFormat::Folder => "Folder (Composite)",
}
}
// Reports whether this format supports resolution scaling.
pub fn supports_resolution(self) -> bool {
!matches!(self, ExportFormat::Svg)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum MaskFormat {
Png,
Jpeg,
Webp,
}
impl Default for MaskFormat {
// Provides default settings for this type.
fn default() -> Self {
Self::Png
}
}
impl MaskFormat {
// Returns the file extension for this format.
pub fn extension(self) -> &'static str {
match self {
MaskFormat::Png => "png",
MaskFormat::Jpeg => "jpeg",
MaskFormat::Webp => "webp",
}
}
// Returns the user-facing label for this option.
pub fn label(self) -> &'static str {
match self {
MaskFormat::Png => ".png",
MaskFormat::Jpeg => ".jpeg",
MaskFormat::Webp => ".webp",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct UiSettings {
pub seed: u64,
pub cols: usize,
pub rows: usize,
pub room_count: usize,
pub min_room_size: usize,
pub max_room_size: usize,
pub square_rooms_only: bool,
pub min_corridor_width: usize,
pub max_corridor_width: usize,
pub corridor_randomness: usize,
pub dead_end_rooms_percent: usize,
pub pack_rooms_without_corridors: bool,
pub door_frequency_percent: usize,
pub room_hallway_door_percent: usize,
pub locked_door_percent: usize,
pub secret_door_percent: usize,
pub allow_middle_corridor_doors: bool,
pub windows_enabled: bool,
pub min_window_width: usize,
pub max_window_width: usize,
pub window_frequency_percent: usize,
pub room_hallway_window_percent: usize,
pub allow_internal_windows: bool,
pub colorblind_mode: bool,
pub composition_mode: bool,
pub export_format: ExportFormat,
pub mask_format: MaskFormat,
pub export_width: u32,
pub export_height: u32,
pub allow_export_aspect_change: bool,
pub export_show_grid: bool,
pub last_export_path: Option<String>,
pub add_tool: AddTool,
pub add_text_value: String,
pub add_text_font_size: u16,
pub add_room_width: usize,
pub add_room_height: usize,
pub add_corridor_width: usize,
pub add_stair_width: usize,
pub add_stair_height: usize,
pub add_marker_size: usize,
pub min_start_marker_size: usize,
pub max_start_marker_size: usize,
pub min_end_marker_size: usize,
pub max_end_marker_size: usize,
pub min_start_marker_count: usize,
pub max_start_marker_count: usize,
pub min_end_marker_count: usize,
pub max_end_marker_count: usize,
pub min_levels: usize,
pub max_levels: usize,
pub active_level_index: usize,
pub export_level_index: usize,
pub trap_frequency_percent: usize,
pub min_traps_per_area: usize,
pub max_traps_per_area: usize,
pub monster_frequency_percent: usize,
pub min_monsters_per_area: usize,
pub max_monsters_per_area: usize,
pub min_stair_width: usize,
pub max_stair_width: usize,
pub min_stair_height: usize,
pub max_stair_height: usize,
pub min_stairs_per_level: usize,
pub max_stairs_per_level: usize,
pub sync_stairs_across_levels: bool,
pub active_tab: Tab,
}
impl Default for UiSettings {
// Provides default settings for this type.
fn default() -> Self {
UiSettings {
seed: 0,
cols: 32,
rows: 32,
room_count: 8,
min_room_size: 3,
max_room_size: 8,
square_rooms_only: true,
min_corridor_width: 1,
max_corridor_width: 3,
corridor_randomness: 50,
dead_end_rooms_percent: 20,
pack_rooms_without_corridors: false,
door_frequency_percent: 50,
room_hallway_door_percent: 20,
locked_door_percent: 10,
secret_door_percent: 5,
allow_middle_corridor_doors: false,
windows_enabled: false,
min_window_width: 2,
max_window_width: 4,
window_frequency_percent: 30,
room_hallway_window_percent: 10,
allow_internal_windows: false,
colorblind_mode: false,
composition_mode: false,
export_format: ExportFormat::Png,
mask_format: MaskFormat::Png,
export_width: 0,
export_height: 0,
allow_export_aspect_change: true,
export_show_grid: false,
last_export_path: None,
add_tool: AddTool::None,
add_text_value: String::from("Text"),
add_text_font_size: 18,
add_room_width: 5,
add_room_height: 5,
add_corridor_width: 2,
add_stair_width: 2,
add_stair_height: 2,
add_marker_size: 1,
min_start_marker_size: 1,
max_start_marker_size: 3,
min_end_marker_size: 1,
max_end_marker_size: 3,
min_start_marker_count: 1,
max_start_marker_count: 3,
min_end_marker_count: 1,
max_end_marker_count: 3,
min_levels: 1,
max_levels: 1,
active_level_index: 0,
trap_frequency_percent: 30,
min_traps_per_area: 1,
max_traps_per_area: 3,
monster_frequency_percent: 30,
min_monsters_per_area: 1,
max_monsters_per_area: 3,
min_stair_width: 2,
max_stair_width: 3,
min_stair_height: 2,
max_stair_height: 3,
min_stairs_per_level: 1,
max_stairs_per_level: 2,
sync_stairs_across_levels: false,
export_level_index: 0,
active_tab: Tab::Generate,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum AddTool {
None,
Room,
Corridor,
Archway,
Door,
LockedDoor,
SecretDoor,
Text,
StartMarker,
EndMarker,
TrapMarker,
MonsterMarker,
Staircase,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct SidePanelResult {
pub settings_changed: bool,
pub reset_clicked: bool,
pub clear_clicked: bool,
pub export_clicked: bool,
pub save_clicked: bool,
pub load_clicked: bool,
pub new_level_clicked: bool,
}