diff --git a/src/exporter.rs b/src/exporter.rs
index 0fb83b4..64191c7 100644
--- a/src/exporter.rs
+++ b/src/exporter.rs
@@ -826,6 +826,54 @@ pub fn build_svg(layout: &DungeonLayout, settings: &UiSettings) -> String {
settings.colorblind_mode,
);
+ // Draw stairs and elevators.
+ for stair in &layout.stairs {
+ let x = g.left() + stair.cell.0 as f32 * g.cell;
+ let y = g.top() + stair.cell.1 as f32 * g.cell;
+ let w = stair.size as f32 * g.cell;
+ let h = stair.size as f32 * g.cell;
+
+ if stair.is_elevator {
+ let fill = if settings.colorblind_mode {
+ "rgb(180,180,180)"
+ } else {
+ "rgb(100,150,200)"
+ };
+ let _ = writeln!(
+ s,
+ ""
+ );
+ let _ = writeln!(
+ s,
+ "E",
+ cx = x + w / 2.0,
+ cy = y + h / 2.0,
+ fs = (g.cell * stair.size as f32 * 0.4).clamp(14.0, 48.0)
+ );
+ } else {
+ let fill = if settings.colorblind_mode {
+ "rgb(180,180,180)"
+ } else {
+ "rgb(200,150,50)"
+ };
+ let _ = writeln!(
+ s,
+ ""
+ );
+ let steps = (stair.size as f32 * 2.0).round() as usize;
+ let step_h = h / steps as f32;
+ for i in 0..steps {
+ let sy = y + (i as f32 * step_h) + step_h * 0.5;
+ let _ = writeln!(
+ s,
+ "",
+ x1 = x + 2.0,
+ x2 = x + w - 2.0
+ );
+ }
+ }
+ }
+
s.push_str("\n");
s
}
diff --git a/src/layout.rs b/src/layout.rs
index c2fece0..7e0fe93 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -61,6 +61,13 @@ pub struct AreaMarker {
pub size: usize,
}
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct Staircase {
+ pub cell: (usize, usize),
+ pub size: usize,
+ pub is_elevator: bool,
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WindowSide {
Left,
@@ -100,6 +107,7 @@ pub struct DungeonLayout {
pub trap_markers: Vec,
pub monster_markers: Vec,
pub packed_rooms: bool,
+ pub stairs: Vec,
}
impl Default for DungeonLayout {
@@ -115,6 +123,7 @@ impl Default for DungeonLayout {
trap_markers: Vec::new(),
monster_markers: Vec::new(),
packed_rooms: false,
+ stairs: Vec::new(),
}
}
}
@@ -186,6 +195,7 @@ pub fn generate_layout(
trap_markers: Vec::new(),
monster_markers: Vec::new(),
packed_rooms: pack_rooms_without_corridors,
+ stairs: Vec::new(),
};
}
@@ -213,6 +223,7 @@ pub fn generate_layout(
trap_markers: Vec::new(),
monster_markers: Vec::new(),
packed_rooms: pack_rooms_without_corridors,
+ stairs: Vec::new(),
};
}
@@ -235,6 +246,7 @@ pub fn generate_layout(
if pack_rooms_without_corridors {
rooms = place_packed_rooms(&room_sizes, &room_edges, cols, rows, &mut room_rng);
let mut layout = DungeonLayout {
+ stairs: Vec::new(),
rooms,
corridors,
doors: Vec::new(),
@@ -306,6 +318,7 @@ pub fn generate_layout(
trap_markers: Vec::new(),
monster_markers: Vec::new(),
packed_rooms: false,
+ stairs: Vec::new(),
};
}
@@ -354,6 +367,7 @@ pub fn generate_layout(
}
let mut layout = DungeonLayout {
+ stairs: Vec::new(),
rooms,
corridors,
doors: Vec::new(),
@@ -983,7 +997,7 @@ fn shared_boundary_edges(a: &Room, b: &Room) -> Vec<((usize, usize), (usize, usi
edges
}
-fn room_index_at_cell(rooms: &[Room], cell: (usize, usize)) -> Option {
+pub fn room_index_at_cell(rooms: &[Room], cell: (usize, usize)) -> Option {
rooms.iter().position(|room| {
cell.0 >= room.x
&& cell.0 < room.x + room.width
diff --git a/src/main.rs b/src/main.rs
index a5a68c7..9cd5a2f 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -18,7 +18,7 @@ use layout::{
};
use startend::{
manual_marker_size, manual_monster_marker_size, manual_trap_marker_size,
- populate_random_markers,
+ populate_random_markers, populate_stairs,
};
use ui::{AddTool, UiSettings, draw_side_panel};
@@ -379,6 +379,7 @@ fn generate_all_levels(settings: &UiSettings) -> Vec {
let layout = populate_random_markers(layout, settings);
levels.push(layout);
}
+ let levels = populate_stairs(levels, settings);
levels
}
@@ -2302,6 +2303,88 @@ fn draw_layout(
hover_marker,
MarkerKind::Monster,
);
+
+ // Draw stairs and elevators.
+ for stair in &layout.stairs {
+ draw_staircase(painter, geometry, stair, colorblind_mode);
+ }
+}
+
+// Draw a staircase or elevator within a room.
+fn draw_staircase(
+ painter: &egui::Painter,
+ geometry: &GridGeometry,
+ stair: &layout::Staircase,
+ colorblind_mode: bool,
+) {
+ let left = geometry.rect.left() + stair.cell.0 as f32 * geometry.cell_size;
+ let top = geometry.rect.top() + stair.cell.1 as f32 * geometry.cell_size;
+ let right = left + stair.size as f32 * geometry.cell_size;
+ let bottom = top + stair.size as f32 * geometry.cell_size;
+ let rect = egui::Rect::from_min_max(egui::pos2(left, top), egui::pos2(right, bottom));
+
+ // Fill with a distinctive color for stairs/elevators.
+ let fill = if colorblind_mode {
+ Color32::from_rgb(180, 180, 180).gamma_multiply(0.4)
+ } else if stair.is_elevator {
+ Color32::from_rgb(100, 150, 200).gamma_multiply(0.35)
+ } else {
+ Color32::from_rgb(200, 150, 50).gamma_multiply(0.35)
+ };
+ painter.rect_filled(rect, 4.0, fill);
+
+ // Draw border.
+ let stroke_color = if colorblind_mode {
+ Color32::BLACK
+ } else if stair.is_elevator {
+ Color32::from_rgb(100, 150, 200)
+ } else {
+ Color32::from_rgb(200, 150, 50)
+ };
+ painter.rect_stroke(
+ rect,
+ 4.0,
+ Stroke::new(2.0, stroke_color),
+ egui::StrokeKind::Middle,
+ );
+
+ // Draw stair lines or elevator symbol.
+ if stair.is_elevator {
+ // Draw elevator symbol: a rectangle with arrows.
+ let center = rect.center();
+ let text = "E";
+ let font_size = (geometry.cell_size * stair.size as f32 * 0.4).clamp(14.0, 48.0);
+ painter.text(
+ center,
+ egui::Align2::CENTER_CENTER,
+ text,
+ egui::FontId::proportional(font_size),
+ if colorblind_mode {
+ Color32::BLACK
+ } else {
+ Color32::WHITE
+ },
+ );
+ } else {
+ // Draw stair lines (horizontal steps).
+ let steps = (stair.size as f32 * 2.0).round() as usize;
+ let step_height = (rect.height() / (steps as f32)).max(1.0);
+ let line_color = if colorblind_mode {
+ Color32::BLACK
+ } else {
+ Color32::from_rgb(150, 100, 30)
+ };
+ for i in 0..steps {
+ let y = rect.top() + (i as f32 * step_height) + step_height * 0.5;
+ painter.line_segment(
+ [
+ egui::pos2(rect.left() + 2.0, y),
+ egui::pos2(rect.right() - 2.0, y),
+ ],
+ Stroke::new(1.5, line_color),
+ );
+ }
+ }
}
// Compute the rectangle for a given grid cell.
diff --git a/src/startend.rs b/src/startend.rs
index 44d3664..a9f9d73 100644
--- a/src/startend.rs
+++ b/src/startend.rs
@@ -1,6 +1,10 @@
use crate::layout::{self, DungeonLayout};
use crate::seed;
use crate::ui::UiSettings;
+use std::collections::HashSet;
+
+// Import room_index_at_cell from layout module.
+use crate::layout::room_index_at_cell;
const START_COUNT_STREAM: u64 = 10_001;
const END_COUNT_STREAM: u64 = 10_002;
@@ -345,3 +349,200 @@ fn pick_random_corridor_cell(
let idx = (seed_value as usize) % corridor.path.len();
Some(corridor.path[idx])
}
+
+// Constant for stair marker stream base
+const STAIR_MARKER_STREAM_BASE: u64 = 21_000;
+
+// Populate stairs (and optionally elevators) across all levels after markers are placed.
+pub fn populate_stairs(
+ mut layouts: Vec,
+ settings: &UiSettings,
+) -> Vec {
+ if settings.min_stairs_per_level == 0 && settings.max_stairs_per_level == 0 {
+ return layouts;
+ }
+
+ let stair_count = if settings.min_stairs_per_level == settings.max_stairs_per_level {
+ settings.min_stairs_per_level
+ } else {
+ let range_seed = seed::derive_seed(settings.seed, 0x2A_3B_4_u64);
+ (range_seed as usize % (settings.max_stairs_per_level - settings.min_stairs_per_level + 1))
+ + settings.min_stairs_per_level
+ };
+
+ if stair_count == 0 {
+ return layouts;
+ }
+
+ let is_elevator = settings.windows_enabled;
+
+ // If syncing stairs, pick the same stair positions for all levels.
+ let mut sync_stair_cells: Vec = Vec::new();
+ if settings.sync_stairs_across_levels && !layouts.is_empty() {
+ let base_settings = settings.clone();
+ let base_layout = layouts[0].clone();
+ sync_stair_cells =
+ pick_stair_positions(&base_layout, &base_settings, stair_count, is_elevator);
+ }
+
+ for (level_idx, layout) in layouts.iter_mut().enumerate() {
+ if settings.sync_stairs_across_levels && !sync_stair_cells.is_empty() {
+ // Use the synced stair positions.
+ layout.stairs = sync_stair_cells
+ .iter()
+ .map(|stair| layout::Staircase {
+ cell: stair.cell,
+ size: random_stair_size(
+ settings,
+ seed::derive_seed(
+ settings.seed,
+ STAIR_MARKER_STREAM_BASE + level_idx as u64,
+ ),
+ ),
+ is_elevator,
+ })
+ .collect();
+ } else {
+ // Generate independent stair positions for this level.
+ layout.stairs = pick_stair_positions(layout, settings, stair_count, is_elevator);
+ }
+ }
+
+ layouts
+}
+
+// Pick random cells within rooms that are valid for stair placement and return Staircase objects.
+fn pick_stair_positions(
+ layout: &DungeonLayout,
+ settings: &UiSettings,
+ count: usize,
+ is_elevator: bool,
+) -> Vec {
+ if layout.rooms.is_empty() || count == 0 {
+ return Vec::new();
+ }
+
+ // Collect valid room cells (rooms without start/end markers on bottom row).
+ let mut valid_cells: Vec<(usize, usize)> = Vec::new();
+ for room in &layout.rooms {
+ let has_start_end_bottom =
+ has_start_or_end_on_bottom_row(room, &layout.start_markers, &layout.end_markers);
+ if !has_start_end_bottom {
+ for x in room.x..(room.x + room.width) {
+ for y in room.y..(room.y + room.height) {
+ valid_cells.push((x, y));
+ }
+ }
+ }
+ }
+
+ if valid_cells.is_empty() {
+ return Vec::new();
+ }
+
+ // Shuffle valid cells deterministically based on seed.
+ let mut shuffled = valid_cells.clone();
+ shuffle_with_seed(
+ &mut shuffled,
+ settings.seed.wrapping_add(STAIR_MARKER_STREAM_BASE),
+ );
+
+ let max_size = settings.max_stair_width.max(settings.max_stair_height);
+ let mut stair_cells: Vec<(usize, usize)> = Vec::new();
+ let mut used_cells: HashSet<(usize, usize)> = HashSet::new();
+
+ for cell in shuffled {
+ if stair_cells.len() >= count {
+ break;
+ }
+ if used_cells.contains(&cell) {
+ continue;
+ }
+ // Check if this cell can accommodate a stair of at least min size.
+ let min_w = settings.min_stair_width;
+ let min_h = settings.min_stair_height;
+ let room_idx = room_index_at_cell(&layout.rooms, cell);
+ if let Some(ri) = room_idx {
+ let room = &layout.rooms[ri];
+ let available_w = room.x + room.width - cell.0;
+ let available_h = room.y + room.height - cell.1;
+ if available_w >= min_w && available_h >= min_h {
+ stair_cells.push(cell);
+ // Mark occupied cells to avoid overlap.
+ for dx in 0..max_size {
+ for dy in 0..max_size {
+ used_cells.insert((cell.0 + dx, cell.1 + dy));
+ }
+ }
+ }
+ }
+ }
+
+ // Convert cells to Staircase objects with random sizes.
+ stair_cells
+ .into_iter()
+ .enumerate()
+ .map(|(i, cell)| layout::Staircase {
+ cell,
+ size: random_stair_size(
+ settings,
+ seed::derive_seed(settings.seed, STAIR_MARKER_STREAM_BASE + i as u64),
+ ),
+ is_elevator,
+ })
+ .collect()
+}
+
+// Check if a room has a start or end marker on its bottom row.
+fn has_start_or_end_on_bottom_row(
+ room: &layout::Room,
+ start_markers: &[layout::AreaMarker],
+ end_markers: &[layout::AreaMarker],
+) -> bool {
+ let bottom_row = room.y + room.height - 1;
+ for marker in start_markers.iter().chain(end_markers.iter()) {
+ let marker_bottom = marker.cell.1 + marker.size;
+ if marker.cell.1 <= bottom_row && marker_bottom > bottom_row {
+ // Marker overlaps with bottom row.
+ return true;
+ }
+ }
+ false
+}
+
+// Pick a random stair size within settings range.
+fn random_stair_size(settings: &UiSettings, seed_value: u64) -> usize {
+ let min_size = settings.min_stair_width.min(settings.min_stair_height);
+ let max_size = settings.max_stair_width.max(settings.max_stair_height);
+ let span = max_size - min_size + 1;
+ min_size + (seed_value as usize % span)
+}
+
+// Shuffle a vector deterministically using a seed.
+fn shuffle_with_seed(vec: &mut [T], seed: u64) {
+ let mut rng = SimpleRng::new(seed);
+ for i in (1..vec.len()).rev() {
+ let j = (rng.next_u32() as usize) % (i + 1);
+ vec.swap(i, j);
+ }
+}
+
+// Simple RNG for deterministic shuffling.
+struct SimpleRng {
+ state: u64,
+}
+
+impl SimpleRng {
+ fn new(seed: u64) -> Self {
+ Self { state: seed }
+ }
+
+ fn next_u32(&mut self) -> u32 {
+ // Simple LCG for deterministic shuffling.
+ self.state = self
+ .state
+ .wrapping_mul(6364136223846793005)
+ .wrapping_add(1442695040888963407);
+ (self.state >> 32) as u32
+ }
+}
diff --git a/src/ui.rs b/src/ui.rs
index 3f06757..489118f 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -151,6 +151,13 @@ pub struct UiSettings {
pub monster_frequency_percent: usize,
pub min_monsters_per_area: usize,
pub max_monsters_per_area: usize,
+ pub min_stair_width: usize,
+ pub max_stair_width: usize,
+ pub min_stair_height: usize,
+ pub max_stair_height: usize,
+ pub min_stairs_per_level: usize,
+ pub max_stairs_per_level: usize,
+ pub sync_stairs_across_levels: bool,
active_tab: Tab,
}
@@ -208,6 +215,13 @@ impl Default for UiSettings {
monster_frequency_percent: 15,
min_monsters_per_area: 1,
max_monsters_per_area: 2,
+ min_stair_width: 1,
+ max_stair_width: 2,
+ min_stair_height: 2,
+ max_stair_height: 3,
+ min_stairs_per_level: 1,
+ max_stairs_per_level: 1,
+ sync_stairs_across_levels: false,
active_tab: Tab::Generate,
}
}
@@ -810,6 +824,114 @@ fn draw_layout_tab(ui: &mut egui::Ui, settings: &mut UiSettings, result: &mut Si
)
.changed();
}
+
+ ui.add_space(12.0);
+ ui.separator();
+ ui.add_space(8.0);
+ ui.label(RichText::new("Staircase Settings").strong());
+ ui.add_space(8.0);
+
+ result.settings_changed |= ui
+ .checkbox(
+ &mut settings.sync_stairs_across_levels,
+ "Sync Stairs Across All Levels",
+ )
+ .changed();
+
+ ui.add_space(8.0);
+ ui.label("Min Stair Width");
+ ui.horizontal(|ui| {
+ result.settings_changed |= ui
+ .add(egui::Slider::new(&mut settings.min_stair_width, 1..=5).show_value(false))
+ .changed();
+ result.settings_changed |= ui
+ .add(
+ egui::DragValue::new(&mut settings.min_stair_width)
+ .speed(1.0)
+ .range(1..=10),
+ )
+ .changed();
+ });
+
+ ui.add_space(8.0);
+ ui.label("Max Stair Width");
+ ui.horizontal(|ui| {
+ result.settings_changed |= ui
+ .add(egui::Slider::new(&mut settings.max_stair_width, 1..=5).show_value(false))
+ .changed();
+ result.settings_changed |= ui
+ .add(
+ egui::DragValue::new(&mut settings.max_stair_width)
+ .speed(1.0)
+ .range(1..=10),
+ )
+ .changed();
+ });
+
+ ui.add_space(8.0);
+ ui.label("Min Stair Height");
+ ui.horizontal(|ui| {
+ result.settings_changed |= ui
+ .add(egui::Slider::new(&mut settings.min_stair_height, 1..=5).show_value(false))
+ .changed();
+ result.settings_changed |= ui
+ .add(
+ egui::DragValue::new(&mut settings.min_stair_height)
+ .speed(1.0)
+ .range(1..=10),
+ )
+ .changed();
+ });
+
+ ui.add_space(8.0);
+ ui.label("Max Stair Height");
+ ui.horizontal(|ui| {
+ result.settings_changed |= ui
+ .add(egui::Slider::new(&mut settings.max_stair_height, 1..=5).show_value(false))
+ .changed();
+ result.settings_changed |= ui
+ .add(
+ egui::DragValue::new(&mut settings.max_stair_height)
+ .speed(1.0)
+ .range(1..=10),
+ )
+ .changed();
+ });
+
+ ui.add_space(8.0);
+ ui.label("Min Stairs Per Level");
+ ui.horizontal(|ui| {
+ result.settings_changed |= ui
+ .add(egui::Slider::new(&mut settings.min_stairs_per_level, 0..=10).show_value(false))
+ .changed();
+ result.settings_changed |= ui
+ .add(
+ egui::DragValue::new(&mut settings.min_stairs_per_level)
+ .speed(1.0)
+ .range(0..=20),
+ )
+ .changed();
+ });
+
+ ui.add_space(8.0);
+ ui.label("Max Stairs Per Level");
+ ui.horizontal(|ui| {
+ result.settings_changed |= ui
+ .add(egui::Slider::new(&mut settings.max_stairs_per_level, 0..=10).show_value(false))
+ .changed();
+ result.settings_changed |= ui
+ .add(
+ egui::DragValue::new(&mut settings.max_stairs_per_level)
+ .speed(1.0)
+ .range(0..=20),
+ )
+ .changed();
+ });
+
+ ui.add_space(8.0);
+ result.settings_changed |= ui
+ .checkbox(&mut settings.windows_enabled, "Enable Elevators")
+ .changed();
}
// Render the Add tab controls.