1use std::collections::HashMap;
8use glam::IVec2;
9
10use crate::procedural::{Rng, DungeonFloor as ProceduralFloor};
11use crate::procedural::dungeon::{
12 IRect, BspSplitter, DungeonGraph, DungeonTheme,
13 Room as ProceduralRoom, Corridor as ProceduralCorridor,
14};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
22pub enum FloorBiome {
23 Ruins,
24 Crypt,
25 Library,
26 Forge,
27 Garden,
28 Void,
29 Chaos,
30 Abyss,
31 Cathedral,
32 Laboratory,
33}
34
35#[derive(Debug, Clone)]
37pub struct BiomeProperties {
38 pub wall_char: char,
39 pub floor_char: char,
40 pub accent_color: (u8, u8, u8),
41 pub ambient_light: f32,
42 pub music_vibe: &'static str,
43 pub hazard_type: HazardType,
44 pub flavor_text: &'static str,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49pub enum HazardType {
50 None,
51 Crumble,
52 Poison,
53 Fire,
54 Ice,
55 Thorns,
56 VoidRift,
57 ChaosBurst,
58 Darkness,
59 Acid,
60}
61
62impl FloorBiome {
63 pub fn properties(self) -> BiomeProperties {
65 match self {
66 FloorBiome::Ruins => BiomeProperties {
67 wall_char: '#',
68 floor_char: '.',
69 accent_color: (180, 160, 120),
70 ambient_light: 0.6,
71 music_vibe: "melancholy_strings",
72 hazard_type: HazardType::Crumble,
73 flavor_text: "Shattered walls echo with the memory of civilization.",
74 },
75 FloorBiome::Crypt => BiomeProperties {
76 wall_char: '\u{2593}',
77 floor_char: ',',
78 accent_color: (100, 100, 130),
79 ambient_light: 0.3,
80 music_vibe: "somber_choir",
81 hazard_type: HazardType::Poison,
82 flavor_text: "The dead stir in their alcoves, whispering warnings.",
83 },
84 FloorBiome::Library => BiomeProperties {
85 wall_char: '\u{2588}',
86 floor_char: ':',
87 accent_color: (140, 100, 60),
88 ambient_light: 0.5,
89 music_vibe: "quiet_ambient",
90 hazard_type: HazardType::None,
91 flavor_text: "Tomes of forbidden knowledge line the endless shelves.",
92 },
93 FloorBiome::Forge => BiomeProperties {
94 wall_char: '%',
95 floor_char: '=',
96 accent_color: (220, 120, 40),
97 ambient_light: 0.7,
98 music_vibe: "industrial_rhythm",
99 hazard_type: HazardType::Fire,
100 flavor_text: "Molten metal pours from ancient crucibles, still burning.",
101 },
102 FloorBiome::Garden => BiomeProperties {
103 wall_char: '&',
104 floor_char: '"',
105 accent_color: (60, 180, 80),
106 ambient_light: 0.8,
107 music_vibe: "ethereal_wind",
108 hazard_type: HazardType::Thorns,
109 flavor_text: "Overgrown vines conceal both beauty and peril.",
110 },
111 FloorBiome::Void => BiomeProperties {
112 wall_char: '\u{2591}',
113 floor_char: '\u{00B7}',
114 accent_color: (30, 10, 60),
115 ambient_light: 0.15,
116 music_vibe: "deep_drone",
117 hazard_type: HazardType::VoidRift,
118 flavor_text: "Reality thins here. The darkness between worlds seeps in.",
119 },
120 FloorBiome::Chaos => BiomeProperties {
121 wall_char: '?',
122 floor_char: '~',
123 accent_color: (200, 50, 200),
124 ambient_light: 0.4,
125 music_vibe: "discordant_pulse",
126 hazard_type: HazardType::ChaosBurst,
127 flavor_text: "The laws of nature are merely suggestions on this floor.",
128 },
129 FloorBiome::Abyss => BiomeProperties {
130 wall_char: '\u{2592}',
131 floor_char: ' ',
132 accent_color: (15, 5, 15),
133 ambient_light: 0.05,
134 music_vibe: "silence_with_heartbeat",
135 hazard_type: HazardType::Darkness,
136 flavor_text: "An endless expanse of nothing. Even sound fears to travel.",
137 },
138 FloorBiome::Cathedral => BiomeProperties {
139 wall_char: '\u{2502}',
140 floor_char: '+',
141 accent_color: (200, 180, 220),
142 ambient_light: 0.9,
143 music_vibe: "grand_organ",
144 hazard_type: HazardType::None,
145 flavor_text: "Stained glass casts prismatic light across the nave.",
146 },
147 FloorBiome::Laboratory => BiomeProperties {
148 wall_char: '\u{2554}',
149 floor_char: '.',
150 accent_color: (80, 200, 100),
151 ambient_light: 0.6,
152 music_vibe: "electronic_hum",
153 hazard_type: HazardType::Acid,
154 flavor_text: "Bubbling vials and crackling arcs of energy fill the air.",
155 },
156 }
157 }
158
159 pub fn from_dungeon_theme(theme: DungeonTheme) -> Self {
161 match theme {
162 DungeonTheme::Cave => FloorBiome::Ruins,
163 DungeonTheme::Cathedral => FloorBiome::Cathedral,
164 DungeonTheme::Laboratory => FloorBiome::Laboratory,
165 DungeonTheme::Temple => FloorBiome::Library,
166 DungeonTheme::Ruins => FloorBiome::Ruins,
167 DungeonTheme::Void => FloorBiome::Void,
168 }
169 }
170
171 pub fn to_dungeon_theme(self) -> DungeonTheme {
173 match self {
174 FloorBiome::Ruins => DungeonTheme::Ruins,
175 FloorBiome::Crypt => DungeonTheme::Cathedral,
176 FloorBiome::Library => DungeonTheme::Temple,
177 FloorBiome::Forge => DungeonTheme::Laboratory,
178 FloorBiome::Garden => DungeonTheme::Cave,
179 FloorBiome::Void => DungeonTheme::Void,
180 FloorBiome::Chaos => DungeonTheme::Void,
181 FloorBiome::Abyss => DungeonTheme::Void,
182 FloorBiome::Cathedral => DungeonTheme::Cathedral,
183 FloorBiome::Laboratory => DungeonTheme::Laboratory,
184 }
185 }
186}
187
188#[derive(Debug, Clone, PartialEq)]
194pub enum RoomType {
195 Normal,
196 Combat,
197 Treasure,
198 Shop,
199 Shrine,
200 Trap,
201 Puzzle,
202 MiniBoss,
203 Boss,
204 ChaosRift,
205 Rest,
206 Secret,
207 Library,
208 Forge,
209}
210
211impl RoomType {
212 pub fn random_for_floor(floor: u32, rng: &mut Rng) -> Self {
214 let mut weights: Vec<(RoomType, f32)> = vec![
215 (RoomType::Normal, 30.0),
216 (RoomType::Combat, 25.0),
217 (RoomType::Treasure, 10.0),
218 (RoomType::Trap, 8.0),
219 (RoomType::Rest, 6.0),
220 (RoomType::Shrine, 5.0),
221 (RoomType::Puzzle, 5.0),
222 (RoomType::Shop, 4.0),
223 (RoomType::Library, 3.0),
224 (RoomType::Forge, 2.0),
225 (RoomType::Secret, 1.5),
226 (RoomType::ChaosRift, 0.5),
227 ];
228 if floor > 25 {
230 for entry in &mut weights {
231 match entry.0 {
232 RoomType::Combat => entry.1 += 10.0,
233 RoomType::Trap => entry.1 += 5.0,
234 RoomType::Rest => entry.1 = (entry.1 - 2.0).max(1.0),
235 RoomType::ChaosRift => entry.1 += 3.0,
236 _ => {}
237 }
238 }
239 }
240 if floor > 50 {
241 for entry in &mut weights {
242 match entry.0 {
243 RoomType::ChaosRift => entry.1 += 5.0,
244 RoomType::Normal => entry.1 = (entry.1 - 10.0).max(5.0),
245 _ => {}
246 }
247 }
248 }
249 let total: f32 = weights.iter().map(|(_, w)| *w).sum();
250 let mut r = rng.next_f32() * total;
251 for (rt, w) in &weights {
252 r -= w;
253 if r <= 0.0 {
254 return rt.clone();
255 }
256 }
257 RoomType::Normal
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
267pub enum RoomShape {
268 Rectangle,
269 LShaped,
270 Circular,
271 Irregular,
272}
273
274impl RoomShape {
275 pub fn random(corruption: u32, rng: &mut Rng) -> Self {
277 let irregular_chance = (corruption as f32 / 100.0).min(0.3);
278 let circular_chance = 0.08;
279 let l_shaped_chance = 0.15;
280 let roll = rng.next_f32();
281 if roll < irregular_chance {
282 RoomShape::Irregular
283 } else if roll < irregular_chance + circular_chance {
284 RoomShape::Circular
285 } else if roll < irregular_chance + circular_chance + l_shaped_chance {
286 RoomShape::LShaped
287 } else {
288 RoomShape::Rectangle
289 }
290 }
291
292 pub fn carve(&self, rect: &IRect, tiles: &mut Vec<Tile>, map_width: usize, rng: &mut Rng) {
294 let x0 = rect.x as usize;
295 let y0 = rect.y as usize;
296 let w = rect.w as usize;
297 let h = rect.h as usize;
298 match self {
299 RoomShape::Rectangle => {
300 for dy in 0..h {
301 for dx in 0..w {
302 let idx = (y0 + dy) * map_width + (x0 + dx);
303 if idx < tiles.len() {
304 tiles[idx] = Tile::Floor;
305 }
306 }
307 }
308 }
309 RoomShape::LShaped => {
310 let half_w = w / 2;
312 let half_h = h / 2;
313 for dy in 0..h {
314 for dx in 0..w {
315 if dx < half_w || dy < half_h {
316 let idx = (y0 + dy) * map_width + (x0 + dx);
317 if idx < tiles.len() {
318 tiles[idx] = Tile::Floor;
319 }
320 }
321 }
322 }
323 }
324 RoomShape::Circular => {
325 let cx = w as f32 / 2.0;
326 let cy = h as f32 / 2.0;
327 let rx = cx - 0.5;
328 let ry = cy - 0.5;
329 for dy in 0..h {
330 for dx in 0..w {
331 let fx = dx as f32 - cx + 0.5;
332 let fy = dy as f32 - cy + 0.5;
333 if (fx * fx) / (rx * rx) + (fy * fy) / (ry * ry) <= 1.0 {
334 let idx = (y0 + dy) * map_width + (x0 + dx);
335 if idx < tiles.len() {
336 tiles[idx] = Tile::Floor;
337 }
338 }
339 }
340 }
341 }
342 RoomShape::Irregular => {
343 let mut grid = vec![false; w * h];
345 for cell in grid.iter_mut() {
347 *cell = rng.next_f32() < 0.55;
348 }
349 for _ in 0..3 {
351 let mut next = grid.clone();
352 for dy in 0..h {
353 for dx in 0..w {
354 let mut alive = 0;
355 for ny in dy.saturating_sub(1)..=(dy + 1).min(h - 1) {
356 for nx in dx.saturating_sub(1)..=(dx + 1).min(w - 1) {
357 if grid[ny * w + nx] {
358 alive += 1;
359 }
360 }
361 }
362 next[dy * w + dx] = alive >= 5;
363 }
364 }
365 grid = next;
366 }
367 for dy in 0..h {
368 for dx in 0..w {
369 if grid[dy * w + dx] {
370 let idx = (y0 + dy) * map_width + (x0 + dx);
371 if idx < tiles.len() {
372 tiles[idx] = Tile::Floor;
373 }
374 }
375 }
376 }
377 }
378 }
379 }
380}
381
382#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub enum CorridorStyle {
389 Straight,
390 Winding,
391 Organic,
392}
393
394impl CorridorStyle {
395 pub fn carve_corridor(
397 &self,
398 from: IVec2,
399 to: IVec2,
400 tiles: &mut Vec<Tile>,
401 map_width: usize,
402 map_height: usize,
403 rng: &mut Rng,
404 ) -> Vec<IVec2> {
405 match self {
406 CorridorStyle::Straight => {
407 Self::carve_l_bend(from, to, tiles, map_width, rng)
408 }
409 CorridorStyle::Winding => {
410 Self::carve_winding(from, to, tiles, map_width, map_height, rng)
411 }
412 CorridorStyle::Organic => {
413 Self::carve_organic(from, to, tiles, map_width, map_height, rng)
414 }
415 }
416 }
417
418 fn carve_l_bend(
419 from: IVec2,
420 to: IVec2,
421 tiles: &mut Vec<Tile>,
422 map_width: usize,
423 rng: &mut Rng,
424 ) -> Vec<IVec2> {
425 let mut path = Vec::new();
426 let bend = if rng.chance(0.5) {
427 IVec2::new(to.x, from.y)
428 } else {
429 IVec2::new(from.x, to.y)
430 };
431 let mut cur = from;
433 while cur != bend {
434 Self::set_tile(cur, tiles, map_width, Tile::Corridor);
435 path.push(cur);
436 if cur.x < bend.x { cur.x += 1; }
437 else if cur.x > bend.x { cur.x -= 1; }
438 if cur.y < bend.y { cur.y += 1; }
439 else if cur.y > bend.y { cur.y -= 1; }
440 }
441 while cur != to {
443 Self::set_tile(cur, tiles, map_width, Tile::Corridor);
444 path.push(cur);
445 if cur.x < to.x { cur.x += 1; }
446 else if cur.x > to.x { cur.x -= 1; }
447 if cur.y < to.y { cur.y += 1; }
448 else if cur.y > to.y { cur.y -= 1; }
449 }
450 Self::set_tile(to, tiles, map_width, Tile::Corridor);
451 path.push(to);
452 path
453 }
454
455 fn carve_winding(
456 from: IVec2,
457 to: IVec2,
458 tiles: &mut Vec<Tile>,
459 map_width: usize,
460 map_height: usize,
461 rng: &mut Rng,
462 ) -> Vec<IVec2> {
463 let mut path = Vec::new();
465 let dx = to.x - from.x;
466 let dy = to.y - from.y;
467 let steps = (dx.abs() + dy.abs()).max(1) as usize;
468 for i in 0..=steps {
469 let t = i as f32 / steps as f32;
470 let base_x = from.x as f32 + dx as f32 * t;
471 let base_y = from.y as f32 + dy as f32 * t;
472 let phase = t * std::f32::consts::PI * 3.0 + rng.next_f32() * 0.3;
474 let amplitude = 2.0 + rng.next_f32() * 1.5;
475 let norm_len = ((dx * dx + dy * dy) as f32).sqrt().max(1.0);
476 let perp_x = -(dy as f32) / norm_len;
477 let perp_y = (dx as f32) / norm_len;
478 let disp = phase.sin() * amplitude;
479 let px = (base_x + perp_x * disp).round() as i32;
480 let py = (base_y + perp_y * disp).round() as i32;
481 let clamped = IVec2::new(
482 px.clamp(1, map_width as i32 - 2),
483 py.clamp(1, map_height as i32 - 2),
484 );
485 Self::set_tile(clamped, tiles, map_width, Tile::Corridor);
486 path.push(clamped);
487 }
488 path
489 }
490
491 fn carve_organic(
492 from: IVec2,
493 to: IVec2,
494 tiles: &mut Vec<Tile>,
495 map_width: usize,
496 map_height: usize,
497 rng: &mut Rng,
498 ) -> Vec<IVec2> {
499 let mut path = Vec::new();
501 let mut cur = from;
502 let max_steps = ((to.x - from.x).abs() + (to.y - from.y).abs()) as usize * 3 + 20;
503 for _ in 0..max_steps {
504 Self::set_tile(cur, tiles, map_width, Tile::Corridor);
505 path.push(cur);
506 if cur == to {
507 break;
508 }
509 let dx = (to.x - cur.x).signum();
510 let dy = (to.y - cur.y).signum();
511 if rng.chance(0.6) {
513 if rng.chance(0.5) && dx != 0 {
514 cur.x += dx;
515 } else if dy != 0 {
516 cur.y += dy;
517 } else {
518 cur.x += dx;
519 }
520 } else {
521 match rng.range_usize(4) {
522 0 => cur.x += 1,
523 1 => cur.x -= 1,
524 2 => cur.y += 1,
525 _ => cur.y -= 1,
526 }
527 }
528 cur.x = cur.x.clamp(1, map_width as i32 - 2);
529 cur.y = cur.y.clamp(1, map_height as i32 - 2);
530 }
531 let widen: Vec<IVec2> = path.clone();
533 for p in &widen {
534 for offset in &[IVec2::new(1, 0), IVec2::new(0, 1)] {
535 let adj = *p + *offset;
536 if adj.x > 0
537 && adj.x < map_width as i32 - 1
538 && adj.y > 0
539 && adj.y < map_height as i32 - 1
540 {
541 if rng.chance(0.3) {
542 Self::set_tile(adj, tiles, map_width, Tile::Corridor);
543 }
544 }
545 }
546 }
547 path
548 }
549
550 fn set_tile(pos: IVec2, tiles: &mut [Tile], map_width: usize, tile: Tile) {
551 let idx = pos.y as usize * map_width + pos.x as usize;
552 if idx < tiles.len() && tiles[idx] == Tile::Wall {
553 tiles[idx] = tile;
554 }
555 }
556}
557
558#[derive(Debug, Clone, Copy, PartialEq, Eq)]
564pub enum Tile {
565 Floor,
566 Wall,
567 Corridor,
568 Door,
569 StairsDown,
570 StairsUp,
571 Trap,
572 Chest,
573 Shrine,
574 ShopCounter,
575 Void,
576 SecretWall,
577 Water,
578 Lava,
579 Ice,
580}
581
582#[derive(Debug, Clone)]
584pub struct TileProperties {
585 pub walkable: bool,
586 pub blocks_sight: bool,
587 pub damage_on_step: Option<f32>,
588 pub slow_factor: f32,
589 pub element: Option<Element>,
590}
591
592#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
594pub enum Element {
595 Fire,
596 Ice,
597 Poison,
598 Lightning,
599 Void,
600 Chaos,
601 Holy,
602 Dark,
603}
604
605impl Tile {
606 pub fn properties(self) -> TileProperties {
608 match self {
609 Tile::Floor => TileProperties {
610 walkable: true, blocks_sight: false,
611 damage_on_step: None, slow_factor: 1.0, element: None,
612 },
613 Tile::Wall => TileProperties {
614 walkable: false, blocks_sight: true,
615 damage_on_step: None, slow_factor: 1.0, element: None,
616 },
617 Tile::Corridor => TileProperties {
618 walkable: true, blocks_sight: false,
619 damage_on_step: None, slow_factor: 1.0, element: None,
620 },
621 Tile::Door => TileProperties {
622 walkable: true, blocks_sight: true,
623 damage_on_step: None, slow_factor: 0.8, element: None,
624 },
625 Tile::StairsDown | Tile::StairsUp => TileProperties {
626 walkable: true, blocks_sight: false,
627 damage_on_step: None, slow_factor: 1.0, element: None,
628 },
629 Tile::Trap => TileProperties {
630 walkable: true, blocks_sight: false,
631 damage_on_step: Some(10.0), slow_factor: 0.5, element: None,
632 },
633 Tile::Chest => TileProperties {
634 walkable: false, blocks_sight: false,
635 damage_on_step: None, slow_factor: 1.0, element: None,
636 },
637 Tile::Shrine => TileProperties {
638 walkable: false, blocks_sight: false,
639 damage_on_step: None, slow_factor: 1.0, element: Some(Element::Holy),
640 },
641 Tile::ShopCounter => TileProperties {
642 walkable: false, blocks_sight: false,
643 damage_on_step: None, slow_factor: 1.0, element: None,
644 },
645 Tile::Void => TileProperties {
646 walkable: false, blocks_sight: true,
647 damage_on_step: Some(999.0), slow_factor: 0.0, element: Some(Element::Void),
648 },
649 Tile::SecretWall => TileProperties {
650 walkable: false, blocks_sight: true,
651 damage_on_step: None, slow_factor: 1.0, element: None,
652 },
653 Tile::Water => TileProperties {
654 walkable: true, blocks_sight: false,
655 damage_on_step: None, slow_factor: 0.5, element: Some(Element::Ice),
656 },
657 Tile::Lava => TileProperties {
658 walkable: true, blocks_sight: false,
659 damage_on_step: Some(25.0), slow_factor: 0.3, element: Some(Element::Fire),
660 },
661 Tile::Ice => TileProperties {
662 walkable: true, blocks_sight: false,
663 damage_on_step: None, slow_factor: 1.5, element: Some(Element::Ice),
664 },
665 }
666 }
667
668 pub fn is_walkable(self) -> bool {
670 self.properties().walkable
671 }
672
673 pub fn blocks_sight(self) -> bool {
675 self.properties().blocks_sight
676 }
677}
678
679#[derive(Debug, Clone)]
685pub struct FloorConfig {
686 pub floor_number: u32,
687 pub room_count_range: (u32, u32),
688 pub corridor_style: CorridorStyle,
689 pub difficulty_mult: f32,
690 pub biome: FloorBiome,
691 pub corruption_level: u32,
692 pub special_rooms: Vec<RoomType>,
693 pub boss_floor: bool,
694}
695
696impl FloorConfig {
697 pub fn for_floor(floor_number: u32) -> Self {
699 let theme = FloorTheme::for_floor(floor_number);
700 let boss_floor = floor_number % 10 == 0 || floor_number == 100;
701 let room_min = if boss_floor { 5 } else { 6 + (floor_number / 15).min(6) };
702 let room_max = if boss_floor { 8 } else { 10 + (floor_number / 10).min(10) };
703 let corridor_style = if floor_number < 11 {
704 CorridorStyle::Straight
705 } else if floor_number < 51 {
706 CorridorStyle::Winding
707 } else {
708 CorridorStyle::Organic
709 };
710 let corruption = (floor_number.saturating_sub(25) * 2).min(200);
711 let mut special_rooms = Vec::new();
712 if boss_floor {
713 special_rooms.push(RoomType::Boss);
714 }
715 if floor_number % 5 == 0 {
716 special_rooms.push(RoomType::Shop);
717 }
718 special_rooms.push(RoomType::Rest);
719
720 FloorConfig {
721 floor_number,
722 room_count_range: (room_min, room_max),
723 corridor_style,
724 difficulty_mult: theme.difficulty_mult,
725 biome: theme.biome,
726 corruption_level: corruption,
727 special_rooms,
728 boss_floor,
729 }
730 }
731}
732
733#[derive(Debug, Clone)]
739pub struct FloorTheme {
740 pub biome: FloorBiome,
741 pub difficulty_mult: f32,
742 pub palette: ThemePalette,
743 pub description: &'static str,
744 pub traps_enabled: bool,
745 pub puzzles_enabled: bool,
746 pub min_safe_rooms: u32,
747}
748
749#[derive(Debug, Clone, Copy)]
751pub struct ThemePalette {
752 pub primary: (u8, u8, u8),
753 pub secondary: (u8, u8, u8),
754 pub accent: (u8, u8, u8),
755}
756
757impl FloorTheme {
758 pub fn for_floor(floor: u32) -> Self {
760 match floor {
761 1..=10 => FloorTheme {
762 biome: FloorBiome::Ruins,
763 difficulty_mult: 1.0,
764 palette: ThemePalette {
765 primary: (180, 140, 100),
766 secondary: (140, 110, 80),
767 accent: (220, 180, 120),
768 },
769 description: "The crumbling entrance. Warm torchlight guides the way.",
770 traps_enabled: false,
771 puzzles_enabled: false,
772 min_safe_rooms: 3,
773 },
774 11..=25 => FloorTheme {
775 biome: FloorBiome::Crypt,
776 difficulty_mult: 1.5,
777 palette: ThemePalette {
778 primary: (100, 100, 140),
779 secondary: (70, 70, 110),
780 accent: (150, 130, 180),
781 },
782 description: "Ancient burial grounds. Traps protect the forgotten dead.",
783 traps_enabled: true,
784 puzzles_enabled: false,
785 min_safe_rooms: 2,
786 },
787 26..=50 => FloorTheme {
788 biome: FloorBiome::Forge,
789 difficulty_mult: 2.0,
790 palette: ThemePalette {
791 primary: (180, 120, 60),
792 secondary: (60, 160, 80),
793 accent: (200, 200, 80),
794 },
795 description: "The workshop depths. Puzzles guard ancient knowledge.",
796 traps_enabled: true,
797 puzzles_enabled: true,
798 min_safe_rooms: 2,
799 },
800 51..=75 => FloorTheme {
801 biome: FloorBiome::Void,
802 difficulty_mult: 3.0,
803 palette: ThemePalette {
804 primary: (40, 20, 80),
805 secondary: (20, 10, 50),
806 accent: (180, 60, 200),
807 },
808 description: "Reality fractures. Corruption seeps through every crack.",
809 traps_enabled: true,
810 puzzles_enabled: true,
811 min_safe_rooms: 1,
812 },
813 76..=99 => FloorTheme {
814 biome: FloorBiome::Abyss,
815 difficulty_mult: 4.5,
816 palette: ThemePalette {
817 primary: (20, 15, 20),
818 secondary: (10, 5, 10),
819 accent: (60, 40, 60),
820 },
821 description: "The bottomless dark. Few safe havens remain.",
822 traps_enabled: true,
823 puzzles_enabled: true,
824 min_safe_rooms: 0,
825 },
826 100 => FloorTheme {
827 biome: FloorBiome::Cathedral,
828 difficulty_mult: 6.0,
829 palette: ThemePalette {
830 primary: (200, 180, 220),
831 secondary: (160, 140, 180),
832 accent: (255, 220, 255),
833 },
834 description: "The Cathedral of the Algorithm. The final reckoning.",
835 traps_enabled: false,
836 puzzles_enabled: false,
837 min_safe_rooms: 0,
838 },
839 _ => FloorTheme {
840 biome: FloorBiome::Chaos,
841 difficulty_mult: 5.0 + (floor as f32 - 100.0) * 0.1,
842 palette: ThemePalette {
843 primary: (200, 50, 200),
844 secondary: (100, 30, 150),
845 accent: (255, 100, 255),
846 },
847 description: "Beyond the Algorithm. Pure chaos reigns.",
848 traps_enabled: true,
849 puzzles_enabled: true,
850 min_safe_rooms: 0,
851 },
852 }
853 }
854}
855
856#[derive(Debug, Clone)]
862pub struct DungeonRoom {
863 pub id: usize,
864 pub rect: IRect,
865 pub room_type: RoomType,
866 pub shape: RoomShape,
867 pub connections: Vec<usize>,
868 pub spawn_points: Vec<IVec2>,
869 pub items: Vec<RoomItem>,
870 pub enemies: Vec<EnemySpawn>,
871 pub visited: bool,
872 pub cleared: bool,
873}
874
875#[derive(Debug, Clone)]
877pub struct RoomItem {
878 pub pos: IVec2,
879 pub kind: RoomItemKind,
880}
881
882#[derive(Debug, Clone, PartialEq)]
884pub enum RoomItemKind {
885 Chest { trapped: bool, loot_tier: u32 },
886 HealingShrine,
887 BuffShrine { buff_name: String, floors_remaining: u32 },
888 RiskShrine,
889 Merchant { item_count: u32, price_mult: f32 },
890 Campfire,
891 ForgeAnvil,
892 LoreBook { entry_id: u32 },
893 SpellScroll { spell_name: String },
894 PuzzleBlock { target: IVec2 },
895}
896
897#[derive(Debug, Clone)]
899pub struct EnemySpawn {
900 pub pos: IVec2,
901 pub stats: ScaledStats,
902 pub name: String,
903 pub element: Option<Element>,
904 pub is_elite: bool,
905 pub abilities: Vec<String>,
906}
907
908#[derive(Debug, Clone)]
910pub struct DungeonCorridor {
911 pub from_room: usize,
912 pub to_room: usize,
913 pub path: Vec<IVec2>,
914 pub style: CorridorStyle,
915 pub has_door: bool,
916}
917
918#[derive(Debug, Clone)]
920pub struct FloorMap {
921 pub width: usize,
922 pub height: usize,
923 pub tiles: Vec<Tile>,
924 pub rooms: Vec<DungeonRoom>,
925 pub corridors: Vec<DungeonCorridor>,
926 pub player_start: IVec2,
927 pub exit_point: IVec2,
928 pub biome: FloorBiome,
929 pub floor_number: u32,
930}
931
932impl FloorMap {
933 pub fn get_tile(&self, x: i32, y: i32) -> Tile {
935 if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
936 return Tile::Wall;
937 }
938 self.tiles[y as usize * self.width + x as usize]
939 }
940
941 pub fn set_tile(&mut self, x: i32, y: i32, tile: Tile) {
943 if x >= 0 && y >= 0 && x < self.width as i32 && y < self.height as i32 {
944 self.tiles[y as usize * self.width + x as usize] = tile;
945 }
946 }
947
948 pub fn room_at(&self, pos: IVec2) -> Option<&DungeonRoom> {
950 self.rooms.iter().find(|r| r.rect.contains(pos.x, pos.y))
951 }
952
953 pub fn room_at_mut(&mut self, pos: IVec2) -> Option<&mut DungeonRoom> {
955 self.rooms.iter_mut().find(|r| r.rect.contains(pos.x, pos.y))
956 }
957
958 pub fn walkable_neighbors(&self, pos: IVec2) -> Vec<IVec2> {
960 let offsets = [
961 IVec2::new(1, 0), IVec2::new(-1, 0),
962 IVec2::new(0, 1), IVec2::new(0, -1),
963 ];
964 offsets
965 .iter()
966 .map(|o| pos + *o)
967 .filter(|p| self.get_tile(p.x, p.y).is_walkable())
968 .collect()
969 }
970
971 pub fn tile_count(&self) -> usize {
973 self.width * self.height
974 }
975}
976
977#[derive(Debug, Clone)]
983pub struct Floor {
984 pub map: FloorMap,
985 pub fog: FogOfWar,
986 pub seed: u64,
987 pub config: FloorConfig,
988 pub enemies_alive: u32,
989 pub total_enemies: u32,
990 pub cleared: bool,
991}
992
993impl Floor {
994 pub fn check_cleared(&mut self) -> bool {
996 self.enemies_alive = self.map.rooms.iter()
997 .flat_map(|r| r.enemies.iter())
998 .count() as u32;
999 self.cleared = self.enemies_alive == 0;
1000 self.cleared
1001 }
1002}
1003
1004#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1010pub enum Visibility {
1011 Unseen,
1012 Seen,
1013 Visible,
1014}
1015
1016#[derive(Debug, Clone)]
1018pub struct FogOfWar {
1019 pub width: usize,
1020 pub height: usize,
1021 pub visibility: Vec<Visibility>,
1022}
1023
1024impl FogOfWar {
1025 pub fn new(width: usize, height: usize) -> Self {
1027 Self {
1028 width,
1029 height,
1030 visibility: vec![Visibility::Unseen; width * height],
1031 }
1032 }
1033
1034 pub fn get(&self, x: i32, y: i32) -> Visibility {
1036 if x < 0 || y < 0 || x >= self.width as i32 || y >= self.height as i32 {
1037 return Visibility::Unseen;
1038 }
1039 self.visibility[y as usize * self.width + x as usize]
1040 }
1041
1042 pub fn set(&mut self, x: i32, y: i32, vis: Visibility) {
1044 if x >= 0 && y >= 0 && x < self.width as i32 && y < self.height as i32 {
1045 self.visibility[y as usize * self.width + x as usize] = vis;
1046 }
1047 }
1048
1049 pub fn fade_visible(&mut self) {
1051 for v in self.visibility.iter_mut() {
1052 if *v == Visibility::Visible {
1053 *v = Visibility::Seen;
1054 }
1055 }
1056 }
1057
1058 pub fn reveal_around(&mut self, center: IVec2, radius: i32, floor_map: &FloorMap) {
1060 self.fade_visible();
1061 let steps = (radius * 8).max(32);
1063 for i in 0..steps {
1064 let angle = (i as f32 / steps as f32) * std::f32::consts::TAU;
1065 let dx = angle.cos();
1066 let dy = angle.sin();
1067 let mut cx = center.x as f32 + 0.5;
1068 let mut cy = center.y as f32 + 0.5;
1069 for _ in 0..=radius {
1070 let tx = cx as i32;
1071 let ty = cy as i32;
1072 if tx < 0 || ty < 0 || tx >= self.width as i32 || ty >= self.height as i32 {
1073 break;
1074 }
1075 self.set(tx, ty, Visibility::Visible);
1076 if floor_map.get_tile(tx, ty).blocks_sight() && (tx != center.x || ty != center.y)
1077 {
1078 break;
1079 }
1080 cx += dx;
1081 cy += dy;
1082 }
1083 }
1084 }
1085
1086 pub fn explored_count(&self) -> usize {
1088 self.visibility
1089 .iter()
1090 .filter(|v| **v != Visibility::Unseen)
1091 .count()
1092 }
1093
1094 pub fn explored_fraction(&self) -> f32 {
1096 let total = self.visibility.len();
1097 if total == 0 {
1098 return 0.0;
1099 }
1100 self.explored_count() as f32 / total as f32
1101 }
1102}
1103
1104#[derive(Debug, Clone)]
1110pub struct BaseStats {
1111 pub hp: f32,
1112 pub damage: f32,
1113 pub defense: f32,
1114 pub speed: f32,
1115 pub xp_value: u32,
1116}
1117
1118#[derive(Debug, Clone)]
1120pub struct ScaledStats {
1121 pub hp: f32,
1122 pub damage: f32,
1123 pub defense: f32,
1124 pub speed: f32,
1125 pub xp_value: u32,
1126 pub level: u32,
1127}
1128
1129pub struct EnemyScaler;
1131
1132impl EnemyScaler {
1133 pub fn scale_enemy(base: &BaseStats, floor: u32, corruption: u32) -> ScaledStats {
1142 let floor_hp_mult = 1.0 + floor as f32 * 0.08;
1143 let floor_dmg_mult = 1.0 + floor as f32 * 0.05;
1144 let floor_def_mult = 1.0 + floor as f32 * 0.03;
1145 let floor_spd_mult = 1.0 + floor as f32 * 0.01;
1146
1147 let corruption_mult = 1.0 + (corruption as f32 / 10.0) * 0.01;
1148
1149 let hp = base.hp * floor_hp_mult * corruption_mult;
1150 let damage = base.damage * floor_dmg_mult * corruption_mult;
1151 let defense = base.defense * floor_def_mult * corruption_mult;
1152 let speed = base.speed * floor_spd_mult * corruption_mult;
1153 let xp_mult = 1.0 + floor as f32 * 0.04;
1154 let xp_value = (base.xp_value as f32 * xp_mult * corruption_mult) as u32;
1155
1156 ScaledStats {
1157 hp,
1158 damage,
1159 defense,
1160 speed,
1161 xp_value,
1162 level: floor,
1163 }
1164 }
1165
1166 pub fn ability_count(floor: u32) -> u32 {
1168 floor / 10
1169 }
1170
1171 pub fn new_types_unlocked(floor: u32) -> bool {
1173 floor % 25 == 0 && floor > 0
1174 }
1175
1176 pub fn elite_prefix(floor: u32, rng: &mut Rng) -> Option<&'static str> {
1178 if floor < 50 {
1179 return None;
1180 }
1181 let prefixes = ["Corrupted", "Ancient", "Void-touched"];
1182 rng.pick(&prefixes).copied()
1183 }
1184
1185 pub fn generate_abilities(floor: u32, rng: &mut Rng) -> Vec<String> {
1187 let count = Self::ability_count(floor) as usize;
1188 let pool = [
1189 "Charge", "Enrage", "Shield Bash", "Poison Strike", "Teleport",
1190 "Summon Minion", "Life Drain", "Fire Breath", "Frost Nova",
1191 "Shadow Step", "Berserk", "Heal Pulse", "Void Bolt", "Chain Lightning",
1192 "Earthquake", "Mirror Image", "Petrify Gaze", "Soul Rend",
1193 ];
1194 let mut abilities = Vec::new();
1195 let mut indices: Vec<usize> = (0..pool.len()).collect();
1196 rng.shuffle(&mut indices);
1197 for &i in indices.iter().take(count.min(pool.len())) {
1198 abilities.push(pool[i].to_string());
1199 }
1200 abilities
1201 }
1202}
1203
1204pub struct RoomPopulator;
1210
1211impl RoomPopulator {
1212 pub fn populate(
1214 room: &mut DungeonRoom,
1215 floor: u32,
1216 corruption: u32,
1217 biome: FloorBiome,
1218 rng: &mut Rng,
1219 ) {
1220 match room.room_type {
1221 RoomType::Combat => Self::populate_combat(room, floor, corruption, biome, rng),
1222 RoomType::Treasure => Self::populate_treasure(room, floor, rng),
1223 RoomType::Shop => Self::populate_shop(room, floor, rng),
1224 RoomType::Shrine => Self::populate_shrine(room, rng),
1225 RoomType::Trap => Self::populate_trap(room, floor, rng),
1226 RoomType::Puzzle => Self::populate_puzzle(room, rng),
1227 RoomType::MiniBoss => Self::populate_miniboss(room, floor, corruption, biome, rng),
1228 RoomType::Boss => Self::populate_boss(room, floor, corruption, biome, rng),
1229 RoomType::ChaosRift => Self::populate_chaos_rift(room, floor, corruption, rng),
1230 RoomType::Rest => Self::populate_rest(room, rng),
1231 RoomType::Secret => Self::populate_secret(room, floor, rng),
1232 RoomType::Library => Self::populate_library(room, floor, rng),
1233 RoomType::Forge => Self::populate_forge(room, rng),
1234 RoomType::Normal => Self::populate_normal(room, floor, corruption, biome, rng),
1235 }
1236 }
1237
1238 fn random_pos_in_room(room: &DungeonRoom, rng: &mut Rng) -> IVec2 {
1239 let r = &room.rect;
1240 IVec2::new(
1241 rng.range_i32(r.x + 1, (r.x + r.w - 2).max(r.x + 1)),
1242 rng.range_i32(r.y + 1, (r.y + r.h - 2).max(r.y + 1)),
1243 )
1244 }
1245
1246 fn element_for_biome(biome: FloorBiome, rng: &mut Rng) -> Option<Element> {
1247 let options = match biome {
1248 FloorBiome::Forge => vec![Element::Fire],
1249 FloorBiome::Crypt => vec![Element::Dark, Element::Poison],
1250 FloorBiome::Garden => vec![Element::Poison],
1251 FloorBiome::Void => vec![Element::Void],
1252 FloorBiome::Chaos => vec![Element::Chaos, Element::Void, Element::Fire, Element::Lightning],
1253 FloorBiome::Abyss => vec![Element::Dark, Element::Void],
1254 FloorBiome::Cathedral => vec![Element::Holy, Element::Lightning],
1255 FloorBiome::Laboratory => vec![Element::Lightning, Element::Poison],
1256 FloorBiome::Library => vec![Element::Fire],
1257 FloorBiome::Ruins => return None,
1258 };
1259 if options.is_empty() {
1260 return None;
1261 }
1262 Some(options[rng.range_usize(options.len())])
1263 }
1264
1265 fn make_enemy(
1266 name: &str,
1267 pos: IVec2,
1268 floor: u32,
1269 corruption: u32,
1270 element: Option<Element>,
1271 rng: &mut Rng,
1272 ) -> EnemySpawn {
1273 let base = BaseStats {
1274 hp: 30.0,
1275 damage: 8.0,
1276 defense: 3.0,
1277 speed: 1.0,
1278 xp_value: 10,
1279 };
1280 let stats = EnemyScaler::scale_enemy(&base, floor, corruption);
1281 let is_elite = floor >= 50 && rng.chance(0.2);
1282 let prefix = if is_elite {
1283 EnemyScaler::elite_prefix(floor, rng).unwrap_or("Elite")
1284 } else {
1285 ""
1286 };
1287 let full_name = if is_elite {
1288 format!("{} {}", prefix, name)
1289 } else {
1290 name.to_string()
1291 };
1292 let abilities = EnemyScaler::generate_abilities(floor, rng);
1293 EnemySpawn {
1294 pos,
1295 stats,
1296 name: full_name,
1297 element,
1298 is_elite,
1299 abilities,
1300 }
1301 }
1302
1303 fn populate_combat(
1304 room: &mut DungeonRoom,
1305 floor: u32,
1306 corruption: u32,
1307 biome: FloorBiome,
1308 rng: &mut Rng,
1309 ) {
1310 let count = rng.range_i32(2, 6) as usize;
1311 let element = Self::element_for_biome(biome, rng);
1312 let enemy_names = ["Shade", "Wraith", "Golem", "Serpent", "Husk", "Warden"];
1313 for _ in 0..count {
1314 let pos = Self::random_pos_in_room(room, rng);
1315 let name = enemy_names[rng.range_usize(enemy_names.len())];
1316 room.enemies.push(Self::make_enemy(name, pos, floor, corruption, element, rng));
1317 }
1318 }
1319
1320 fn populate_treasure(room: &mut DungeonRoom, floor: u32, rng: &mut Rng) {
1321 let count = rng.range_i32(1, 3) as usize;
1322 for _ in 0..count {
1323 let pos = Self::random_pos_in_room(room, rng);
1324 let trapped = rng.chance(0.25);
1325 let tier = 1 + floor / 10;
1326 room.items.push(RoomItem {
1327 pos,
1328 kind: RoomItemKind::Chest { trapped, loot_tier: tier },
1329 });
1330 }
1331 }
1332
1333 fn populate_shop(room: &mut DungeonRoom, floor: u32, rng: &mut Rng) {
1334 let center = room.rect.center();
1335 let item_count = rng.range_i32(4, 8) as u32;
1336 let price_mult = 1.0 + floor as f32 * 0.05;
1337 room.items.push(RoomItem {
1338 pos: center,
1339 kind: RoomItemKind::Merchant { item_count, price_mult },
1340 });
1341 }
1342
1343 fn populate_shrine(room: &mut DungeonRoom, rng: &mut Rng) {
1344 let pos = room.rect.center();
1345 let roll = rng.next_f32();
1346 let kind = if roll < 0.4 {
1347 RoomItemKind::HealingShrine
1348 } else if roll < 0.75 {
1349 let buffs = ["Fortitude", "Swiftness", "Might", "Arcane Sight", "Iron Skin"];
1350 let buff_name = buffs[rng.range_usize(buffs.len())].to_string();
1351 RoomItemKind::BuffShrine {
1352 buff_name,
1353 floors_remaining: 5,
1354 }
1355 } else {
1356 RoomItemKind::RiskShrine
1357 };
1358 room.items.push(RoomItem { pos, kind });
1359 }
1360
1361 fn populate_trap(room: &mut DungeonRoom, floor: u32, rng: &mut Rng) {
1362 let count = rng.range_i32(2, 4) as usize;
1363 let trap_names = ["Pendulum", "Spikes", "Arrows", "Flames"];
1364 for _ in 0..count {
1365 let pos = Self::random_pos_in_room(room, rng);
1366 room.spawn_points.push(pos);
1367 }
1368 if rng.chance(0.5) {
1370 let pos = Self::random_pos_in_room(room, rng);
1371 let tier = 1 + floor / 15;
1372 room.items.push(RoomItem {
1373 pos,
1374 kind: RoomItemKind::Chest { trapped: false, loot_tier: tier },
1375 });
1376 }
1377 let _ = trap_names; }
1379
1380 fn populate_puzzle(room: &mut DungeonRoom, rng: &mut Rng) {
1381 let count = rng.range_i32(2, 3) as usize;
1383 for _ in 0..count {
1384 let pos = Self::random_pos_in_room(room, rng);
1385 let target = Self::random_pos_in_room(room, rng);
1386 room.items.push(RoomItem {
1387 pos,
1388 kind: RoomItemKind::PuzzleBlock { target },
1389 });
1390 }
1391 }
1392
1393 fn populate_miniboss(
1394 room: &mut DungeonRoom,
1395 floor: u32,
1396 corruption: u32,
1397 biome: FloorBiome,
1398 rng: &mut Rng,
1399 ) {
1400 let element = Self::element_for_biome(biome, rng);
1401 let pos = room.rect.center();
1402 let base = BaseStats {
1403 hp: 120.0,
1404 damage: 25.0,
1405 defense: 10.0,
1406 speed: 0.8,
1407 xp_value: 80,
1408 };
1409 let stats = EnemyScaler::scale_enemy(&base, floor, corruption);
1410 let abilities = EnemyScaler::generate_abilities(floor, rng);
1411 let boss_names = ["Guardian", "Sentinel", "Revenant", "Behemoth", "Archon"];
1412 let name = boss_names[rng.range_usize(boss_names.len())].to_string();
1413 room.enemies.push(EnemySpawn {
1414 pos,
1415 stats,
1416 name,
1417 element,
1418 is_elite: true,
1419 abilities,
1420 });
1421 }
1422
1423 fn populate_boss(
1424 room: &mut DungeonRoom,
1425 floor: u32,
1426 corruption: u32,
1427 biome: FloorBiome,
1428 rng: &mut Rng,
1429 ) {
1430 let element = Self::element_for_biome(biome, rng);
1431 let pos = room.rect.center();
1432 let base = BaseStats {
1433 hp: 500.0,
1434 damage: 50.0,
1435 defense: 25.0,
1436 speed: 0.6,
1437 xp_value: 500,
1438 };
1439 let stats = EnemyScaler::scale_enemy(&base, floor, corruption);
1440 let mut abilities = EnemyScaler::generate_abilities(floor, rng);
1441 let extra = ["Phase Shift", "Devastating Slam", "Summon Elites"];
1443 for a in &extra {
1444 if abilities.len() < 3 {
1445 abilities.push(a.to_string());
1446 }
1447 }
1448 let boss_name = if floor == 100 {
1449 "The Algorithm Reborn".to_string()
1450 } else {
1451 let titles = [
1452 "The Hollow King", "Archlich Verath", "Ironclad Titan",
1453 "The Void Weaver", "Chaos Incarnate", "The Silent Dread",
1454 ];
1455 titles[rng.range_usize(titles.len())].to_string()
1456 };
1457 room.enemies.push(EnemySpawn {
1458 pos,
1459 stats,
1460 name: boss_name,
1461 element,
1462 is_elite: true,
1463 abilities,
1464 });
1465 }
1466
1467 fn populate_chaos_rift(
1468 room: &mut DungeonRoom,
1469 floor: u32,
1470 corruption: u32,
1471 rng: &mut Rng,
1472 ) {
1473 let initial_count = rng.range_i32(1, 3) as usize;
1475 for _ in 0..initial_count {
1476 let pos = Self::random_pos_in_room(room, rng);
1477 let names = ["Rift Spawn", "Chaos Wisp", "Void Tendril"];
1478 let name = names[rng.range_usize(names.len())];
1479 room.enemies.push(Self::make_enemy(name, pos, floor, corruption, Some(Element::Chaos), rng));
1480 }
1481 }
1482
1483 fn populate_rest(room: &mut DungeonRoom, rng: &mut Rng) {
1484 let pos = room.rect.center();
1485 room.items.push(RoomItem {
1486 pos,
1487 kind: RoomItemKind::Campfire,
1488 });
1489 if rng.chance(0.3) {
1491 let pos2 = Self::random_pos_in_room(room, rng);
1492 room.items.push(RoomItem {
1493 pos: pos2,
1494 kind: RoomItemKind::HealingShrine,
1495 });
1496 }
1497 }
1498
1499 fn populate_secret(room: &mut DungeonRoom, floor: u32, rng: &mut Rng) {
1500 let pos = room.rect.center();
1502 let tier = 3 + floor / 10;
1503 room.items.push(RoomItem {
1504 pos,
1505 kind: RoomItemKind::Chest { trapped: false, loot_tier: tier },
1506 });
1507 if rng.chance(0.5) {
1509 let pos2 = Self::random_pos_in_room(room, rng);
1510 let spells = ["Meteor", "Time Stop", "Mass Heal", "Void Gate", "Chain Bolt"];
1511 room.items.push(RoomItem {
1512 pos: pos2,
1513 kind: RoomItemKind::SpellScroll {
1514 spell_name: spells[rng.range_usize(spells.len())].to_string(),
1515 },
1516 });
1517 }
1518 }
1519
1520 fn populate_library(room: &mut DungeonRoom, floor: u32, rng: &mut Rng) {
1521 let book_count = rng.range_i32(1, 3) as usize;
1522 for i in 0..book_count {
1523 let pos = Self::random_pos_in_room(room, rng);
1524 room.items.push(RoomItem {
1525 pos,
1526 kind: RoomItemKind::LoreBook { entry_id: floor * 10 + i as u32 },
1527 });
1528 }
1529 if rng.chance(0.4) {
1531 let pos = Self::random_pos_in_room(room, rng);
1532 let spells = ["Fireball", "Frost Shield", "Lightning Arc", "Shadow Cloak"];
1533 room.items.push(RoomItem {
1534 pos,
1535 kind: RoomItemKind::SpellScroll {
1536 spell_name: spells[rng.range_usize(spells.len())].to_string(),
1537 },
1538 });
1539 }
1540 }
1541
1542 fn populate_forge(room: &mut DungeonRoom, rng: &mut Rng) {
1543 let pos = room.rect.center();
1544 room.items.push(RoomItem {
1545 pos,
1546 kind: RoomItemKind::ForgeAnvil,
1547 });
1548 }
1549
1550 fn populate_normal(
1551 room: &mut DungeonRoom,
1552 floor: u32,
1553 corruption: u32,
1554 biome: FloorBiome,
1555 rng: &mut Rng,
1556 ) {
1557 if rng.chance(0.3) {
1559 let pos = Self::random_pos_in_room(room, rng);
1560 let element = Self::element_for_biome(biome, rng);
1561 room.enemies.push(Self::make_enemy("Wanderer", pos, floor, corruption, element, rng));
1562 }
1563 }
1564}
1565
1566pub struct FloorGenerator;
1572
1573impl FloorGenerator {
1574 pub fn generate(config: &FloorConfig, seed: u64) -> Floor {
1576 let mut rng = Rng::new(seed ^ (config.floor_number as u64).wrapping_mul(0xCAFEBABE));
1577
1578 let base_w = 60 + (config.floor_number as usize * 4).min(140);
1580 let base_h = 40 + (config.floor_number as usize * 3).min(80);
1581 let map_width = base_w.min(200);
1582 let map_height = base_h.min(120);
1583
1584 let mut tiles = vec![Tile::Wall; map_width * map_height];
1586
1587 let min_room = 7;
1589 let max_depth = 4 + config.floor_number / 15;
1590 let bsp = BspSplitter::new(min_room, 0.2, max_depth.min(8));
1591 let graph = bsp.generate(map_width as i32, map_height as i32, &mut rng);
1592
1593 let mut rooms: Vec<DungeonRoom> = Vec::new();
1595 for (i, proc_room) in graph.rooms.iter().enumerate() {
1596 let mut rect = proc_room.rect;
1598 if rect.w > 15 { rect.w = 15; }
1599 if rect.h > 15 { rect.h = 15; }
1600
1601 let shape = RoomShape::random(config.corruption_level, &mut rng);
1602 shape.carve(&rect, &mut tiles, map_width, &mut rng);
1603
1604 let room_type = Self::assign_room_type(i, graph.rooms.len(), config, &mut rng);
1605 let mut dungeon_room = DungeonRoom {
1606 id: i,
1607 rect,
1608 room_type,
1609 shape,
1610 connections: proc_room.connections.clone(),
1611 spawn_points: proc_room.spawns.clone(),
1612 items: Vec::new(),
1613 enemies: Vec::new(),
1614 visited: false,
1615 cleared: false,
1616 };
1617
1618 RoomPopulator::populate(
1620 &mut dungeon_room,
1621 config.floor_number,
1622 config.corruption_level,
1623 config.biome,
1624 &mut rng,
1625 );
1626 rooms.push(dungeon_room);
1627 }
1628
1629 let mut corridors: Vec<DungeonCorridor> = Vec::new();
1631 for proc_corr in &graph.corridors {
1632 let from_center = if proc_corr.from < rooms.len() {
1633 rooms[proc_corr.from].rect.center()
1634 } else {
1635 IVec2::ZERO
1636 };
1637 let to_center = if proc_corr.to < rooms.len() {
1638 rooms[proc_corr.to].rect.center()
1639 } else {
1640 IVec2::ZERO
1641 };
1642 let path = config.corridor_style.carve_corridor(
1643 from_center,
1644 to_center,
1645 &mut tiles,
1646 map_width,
1647 map_height,
1648 &mut rng,
1649 );
1650 corridors.push(DungeonCorridor {
1651 from_room: proc_corr.from,
1652 to_room: proc_corr.to,
1653 path,
1654 style: config.corridor_style,
1655 has_door: proc_corr.has_door,
1656 });
1657 }
1658
1659 for corr in &corridors {
1661 if corr.has_door {
1662 if let Some(first) = corr.path.first() {
1663 let idx = first.y as usize * map_width + first.x as usize;
1664 if idx < tiles.len() && tiles[idx] == Tile::Corridor {
1665 tiles[idx] = Tile::Door;
1666 }
1667 }
1668 if let Some(last) = corr.path.last() {
1669 let idx = last.y as usize * map_width + last.x as usize;
1670 if idx < tiles.len() && tiles[idx] == Tile::Corridor {
1671 tiles[idx] = Tile::Door;
1672 }
1673 }
1674 }
1675 }
1676
1677 let player_start = if !rooms.is_empty() {
1679 rooms[0].rect.center()
1680 } else {
1681 IVec2::new(map_width as i32 / 2, map_height as i32 / 2)
1682 };
1683 let exit_point = if rooms.len() > 1 {
1684 rooms[rooms.len() - 1].rect.center()
1685 } else {
1686 player_start
1687 };
1688
1689 {
1691 let idx = player_start.y as usize * map_width + player_start.x as usize;
1692 if idx < tiles.len() {
1693 tiles[idx] = Tile::StairsUp;
1694 }
1695 }
1696 {
1697 let idx = exit_point.y as usize * map_width + exit_point.x as usize;
1698 if idx < tiles.len() {
1699 tiles[idx] = Tile::StairsDown;
1700 }
1701 }
1702
1703 for room in &rooms {
1705 for item in &room.items {
1706 let idx = item.pos.y as usize * map_width + item.pos.x as usize;
1707 if idx < tiles.len() {
1708 match &item.kind {
1709 RoomItemKind::Chest { .. } => tiles[idx] = Tile::Chest,
1710 RoomItemKind::HealingShrine
1711 | RoomItemKind::BuffShrine { .. }
1712 | RoomItemKind::RiskShrine => tiles[idx] = Tile::Shrine,
1713 RoomItemKind::Merchant { .. } => tiles[idx] = Tile::ShopCounter,
1714 _ => {}
1715 }
1716 }
1717 }
1718 }
1719
1720 for room in &rooms {
1722 if room.room_type == RoomType::Secret {
1723 let candidates = [
1725 IVec2::new(room.rect.x - 1, room.rect.y + room.rect.h / 2),
1726 IVec2::new(room.rect.x + room.rect.w, room.rect.y + room.rect.h / 2),
1727 IVec2::new(room.rect.x + room.rect.w / 2, room.rect.y - 1),
1728 IVec2::new(room.rect.x + room.rect.w / 2, room.rect.y + room.rect.h),
1729 ];
1730 for c in &candidates {
1731 let ci = c.y as usize * map_width + c.x as usize;
1732 if ci < tiles.len() && tiles[ci] == Tile::Wall {
1733 tiles[ci] = Tile::SecretWall;
1734 break;
1735 }
1736 }
1737 }
1738 }
1739
1740 Self::place_hazard_tiles(config, &mut tiles, map_width, map_height, &mut rng);
1742
1743 let total_enemies = rooms.iter().map(|r| r.enemies.len() as u32).sum();
1744
1745 let floor_map = FloorMap {
1746 width: map_width,
1747 height: map_height,
1748 tiles,
1749 rooms,
1750 corridors,
1751 player_start,
1752 exit_point,
1753 biome: config.biome,
1754 floor_number: config.floor_number,
1755 };
1756
1757 let fog = FogOfWar::new(map_width, map_height);
1758
1759 Floor {
1760 map: floor_map,
1761 fog,
1762 seed,
1763 config: config.clone(),
1764 enemies_alive: total_enemies,
1765 total_enemies,
1766 cleared: total_enemies == 0,
1767 }
1768 }
1769
1770 fn assign_room_type(
1773 index: usize,
1774 total: usize,
1775 config: &FloorConfig,
1776 rng: &mut Rng,
1777 ) -> RoomType {
1778 if index == 0 {
1779 return RoomType::Rest; }
1781 if index == total - 1 && config.boss_floor {
1782 return RoomType::Boss;
1783 }
1784 if index == total - 1 {
1785 return RoomType::Normal;
1786 }
1787 let special_index = index.saturating_sub(1);
1789 if special_index < config.special_rooms.len() {
1790 return config.special_rooms[special_index].clone();
1791 }
1792 RoomType::random_for_floor(config.floor_number, rng)
1794 }
1795
1796 fn place_hazard_tiles(
1798 config: &FloorConfig,
1799 tiles: &mut Vec<Tile>,
1800 width: usize,
1801 height: usize,
1802 rng: &mut Rng,
1803 ) {
1804 let props = config.biome.properties();
1805 let hazard_tile = match props.hazard_type {
1806 HazardType::Fire => Some(Tile::Lava),
1807 HazardType::Ice => Some(Tile::Ice),
1808 HazardType::Acid | HazardType::Poison => Some(Tile::Water),
1809 HazardType::VoidRift | HazardType::Darkness => Some(Tile::Void),
1810 _ => None,
1811 };
1812 if let Some(ht) = hazard_tile {
1813 let count = rng.range_i32(3, 8 + config.floor_number as i32 / 5) as usize;
1814 for _ in 0..count {
1815 let x = rng.range_i32(2, width as i32 - 3);
1816 let y = rng.range_i32(2, height as i32 - 3);
1817 let idx = y as usize * width + x as usize;
1818 if idx < tiles.len() && tiles[idx] == Tile::Floor {
1819 tiles[idx] = ht;
1820 }
1821 }
1822 }
1823 }
1824}
1825
1826#[derive(Debug, Clone)]
1832pub struct MinimapGlyph {
1833 pub x: i32,
1834 pub y: i32,
1835 pub ch: char,
1836 pub color: (u8, u8, u8),
1837}
1838
1839pub struct Minimap;
1841
1842impl Minimap {
1843 pub fn render_minimap(
1846 floor: &FloorMap,
1847 player_pos: IVec2,
1848 fog: &FogOfWar,
1849 ) -> Vec<MinimapGlyph> {
1850 let scale = 4; let mw = (floor.width + scale - 1) / scale;
1852 let mh = (floor.height + scale - 1) / scale;
1853 let biome_props = floor.biome.properties();
1854
1855 let mut glyphs = Vec::with_capacity(mw * mh);
1856
1857 for my in 0..mh {
1858 for mx in 0..mw {
1859 let tx = (mx * scale) as i32;
1860 let ty = (my * scale) as i32;
1861
1862 let mut floor_count = 0u32;
1864 let mut wall_count = 0u32;
1865 let mut special = false;
1866 let mut any_visible = false;
1867
1868 for dy in 0..scale as i32 {
1869 for dx in 0..scale as i32 {
1870 let sx = tx + dx;
1871 let sy = ty + dy;
1872 let vis = fog.get(sx, sy);
1873 if vis == Visibility::Unseen {
1874 continue;
1875 }
1876 any_visible = true;
1877 let tile = floor.get_tile(sx, sy);
1878 match tile {
1879 Tile::Floor | Tile::Corridor => floor_count += 1,
1880 Tile::Wall | Tile::Void => wall_count += 1,
1881 _ => {
1882 special = true;
1883 floor_count += 1;
1884 }
1885 }
1886 }
1887 }
1888
1889 if !any_visible {
1890 continue; }
1892
1893 let (ch, color) = if special {
1894 ('!', (255, 255, 100))
1895 } else if floor_count > wall_count {
1896 ('.', biome_props.accent_color)
1897 } else {
1898 ('#', (80, 80, 80))
1899 };
1900
1901 glyphs.push(MinimapGlyph {
1902 x: mx as i32,
1903 y: my as i32,
1904 ch,
1905 color,
1906 });
1907 }
1908 }
1909
1910 let px = player_pos.x as usize / scale;
1912 let py = player_pos.y as usize / scale;
1913 glyphs.push(MinimapGlyph {
1914 x: px as i32,
1915 y: py as i32,
1916 ch: '@',
1917 color: (255, 255, 255),
1918 });
1919
1920 let ex = floor.exit_point.x;
1922 let ey = floor.exit_point.y;
1923 if fog.get(ex, ey) != Visibility::Unseen {
1924 glyphs.push(MinimapGlyph {
1925 x: ex as i32 / scale as i32,
1926 y: ey as i32 / scale as i32,
1927 ch: '>',
1928 color: (100, 255, 100),
1929 });
1930 }
1931
1932 glyphs
1933 }
1934}
1935
1936pub struct DungeonManager {
1942 seed: u64,
1943 current_floor_number: u32,
1944 current_floor: Option<Floor>,
1945 floor_history: HashMap<u32, Floor>,
1946 max_floor_reached: u32,
1947}
1948
1949impl DungeonManager {
1950 pub fn new(seed: u64) -> Self {
1952 Self {
1953 seed,
1954 current_floor_number: 0,
1955 current_floor: None,
1956 floor_history: HashMap::new(),
1957 max_floor_reached: 0,
1958 }
1959 }
1960
1961 pub fn start(&mut self) {
1963 self.current_floor_number = 1;
1964 let config = FloorConfig::for_floor(1);
1965 let floor = FloorGenerator::generate(&config, self.seed);
1966 self.current_floor = Some(floor);
1967 self.max_floor_reached = 1;
1968 }
1969
1970 pub fn descend(&mut self) {
1972 if let Some(floor) = self.current_floor.take() {
1974 self.floor_history.insert(self.current_floor_number, floor);
1975 }
1976 self.current_floor_number += 1;
1977
1978 if let Some(existing) = self.floor_history.remove(&self.current_floor_number) {
1980 self.current_floor = Some(existing);
1981 } else {
1982 let config = FloorConfig::for_floor(self.current_floor_number);
1983 let floor_seed = self.seed.wrapping_add(self.current_floor_number as u64 * 0x517cc1b727220a95);
1984 let floor = FloorGenerator::generate(&config, floor_seed);
1985 self.current_floor = Some(floor);
1986 }
1987
1988 if self.current_floor_number > self.max_floor_reached {
1989 self.max_floor_reached = self.current_floor_number;
1990 }
1991 }
1992
1993 pub fn ascend(&mut self) {
1995 if self.current_floor_number <= 1 {
1996 return;
1997 }
1998 if let Some(floor) = self.current_floor.take() {
1999 self.floor_history.insert(self.current_floor_number, floor);
2000 }
2001 self.current_floor_number -= 1;
2002 if let Some(existing) = self.floor_history.remove(&self.current_floor_number) {
2003 self.current_floor = Some(existing);
2004 }
2005 }
2006
2007 pub fn current_floor(&self) -> Option<&Floor> {
2009 self.current_floor.as_ref()
2010 }
2011
2012 pub fn current_floor_mut(&mut self) -> Option<&mut Floor> {
2014 self.current_floor.as_mut()
2015 }
2016
2017 pub fn get_room_at(&self, pos: IVec2) -> Option<&DungeonRoom> {
2019 self.current_floor.as_ref().and_then(|f| f.map.room_at(pos))
2020 }
2021
2022 pub fn reveal_around(&mut self, pos: IVec2, radius: i32) {
2024 if let Some(floor) = &mut self.current_floor {
2025 floor.fog.reveal_around(pos, radius, &floor.map);
2026 }
2027 }
2028
2029 pub fn floor_number(&self) -> u32 {
2031 self.current_floor_number
2032 }
2033
2034 pub fn max_floor(&self) -> u32 {
2036 self.max_floor_reached
2037 }
2038
2039 pub fn seed(&self) -> u64 {
2041 self.seed
2042 }
2043
2044 pub fn history_size(&self) -> usize {
2046 self.floor_history.len()
2047 }
2048}
2049
2050#[cfg(test)]
2055mod tests {
2056 use super::*;
2057
2058 #[test]
2059 fn test_bsp_generation_produces_rooms() {
2060 let config = FloorConfig::for_floor(1);
2061 let floor = FloorGenerator::generate(&config, 42);
2062 assert!(!floor.map.rooms.is_empty(), "BSP should generate at least one room");
2063 assert!(floor.map.rooms.len() >= 2, "Floor should have at least 2 rooms");
2064 }
2065
2066 #[test]
2067 fn test_bsp_generation_deterministic() {
2068 let config = FloorConfig::for_floor(5);
2069 let floor_a = FloorGenerator::generate(&config, 12345);
2070 let floor_b = FloorGenerator::generate(&config, 12345);
2071 assert_eq!(floor_a.map.rooms.len(), floor_b.map.rooms.len());
2072 assert_eq!(floor_a.map.tiles, floor_b.map.tiles);
2073 }
2074
2075 #[test]
2076 fn test_floor_has_start_and_exit() {
2077 let config = FloorConfig::for_floor(1);
2078 let floor = FloorGenerator::generate(&config, 99);
2079 let start_tile = floor.map.get_tile(floor.map.player_start.x, floor.map.player_start.y);
2080 let exit_tile = floor.map.get_tile(floor.map.exit_point.x, floor.map.exit_point.y);
2081 assert_eq!(start_tile, Tile::StairsUp);
2082 assert_eq!(exit_tile, Tile::StairsDown);
2083 }
2084
2085 #[test]
2086 fn test_room_population_combat() {
2087 let mut rng = Rng::new(42);
2088 let rect = IRect::new(5, 5, 10, 10);
2089 let mut room = DungeonRoom {
2090 id: 0,
2091 rect,
2092 room_type: RoomType::Combat,
2093 shape: RoomShape::Rectangle,
2094 connections: vec![],
2095 spawn_points: vec![],
2096 items: vec![],
2097 enemies: vec![],
2098 visited: false,
2099 cleared: false,
2100 };
2101 RoomPopulator::populate(&mut room, 10, 0, FloorBiome::Ruins, &mut rng);
2102 assert!(
2103 room.enemies.len() >= 2 && room.enemies.len() <= 6,
2104 "Combat room should have 2-6 enemies, got {}",
2105 room.enemies.len()
2106 );
2107 }
2108
2109 #[test]
2110 fn test_room_population_shop() {
2111 let mut rng = Rng::new(42);
2112 let rect = IRect::new(5, 5, 10, 10);
2113 let mut room = DungeonRoom {
2114 id: 0,
2115 rect,
2116 room_type: RoomType::Shop,
2117 shape: RoomShape::Rectangle,
2118 connections: vec![],
2119 spawn_points: vec![],
2120 items: vec![],
2121 enemies: vec![],
2122 visited: false,
2123 cleared: false,
2124 };
2125 RoomPopulator::populate(&mut room, 10, 0, FloorBiome::Ruins, &mut rng);
2126 assert!(!room.items.is_empty(), "Shop room should have merchant");
2127 let has_merchant = room.items.iter().any(|i| matches!(i.kind, RoomItemKind::Merchant { .. }));
2128 assert!(has_merchant, "Shop room should contain a Merchant item");
2129 }
2130
2131 #[test]
2132 fn test_enemy_scaling() {
2133 let base = BaseStats {
2134 hp: 100.0,
2135 damage: 20.0,
2136 defense: 5.0,
2137 speed: 1.0,
2138 xp_value: 10,
2139 };
2140 let scaled_f1 = EnemyScaler::scale_enemy(&base, 1, 0);
2141 let scaled_f50 = EnemyScaler::scale_enemy(&base, 50, 0);
2142 assert!(
2143 scaled_f50.hp > scaled_f1.hp,
2144 "Higher floor enemies should have more HP"
2145 );
2146 assert!(
2147 scaled_f50.damage > scaled_f1.damage,
2148 "Higher floor enemies should do more damage"
2149 );
2150
2151 let scaled_corrupt = EnemyScaler::scale_enemy(&base, 50, 100);
2153 assert!(
2154 scaled_corrupt.hp > scaled_f50.hp,
2155 "Corruption should increase HP"
2156 );
2157 }
2158
2159 #[test]
2160 fn test_enemy_scaling_formula() {
2161 let base = BaseStats {
2162 hp: 100.0,
2163 damage: 20.0,
2164 defense: 5.0,
2165 speed: 1.0,
2166 xp_value: 10,
2167 };
2168 let scaled = EnemyScaler::scale_enemy(&base, 10, 0);
2169 let expected_hp = 100.0 * (1.0 + 10.0 * 0.08);
2170 assert!(
2171 (scaled.hp - expected_hp).abs() < 0.01,
2172 "HP should be base * (1 + floor * 0.08)"
2173 );
2174 let expected_dmg = 20.0 * (1.0 + 10.0 * 0.05);
2175 assert!(
2176 (scaled.damage - expected_dmg).abs() < 0.01,
2177 "Damage should be base * (1 + floor * 0.05)"
2178 );
2179 }
2180
2181 #[test]
2182 fn test_enemy_abilities_scale_with_floor() {
2183 assert_eq!(EnemyScaler::ability_count(5), 0);
2184 assert_eq!(EnemyScaler::ability_count(10), 1);
2185 assert_eq!(EnemyScaler::ability_count(30), 3);
2186 }
2187
2188 #[test]
2189 fn test_elite_prefix_only_after_floor_50() {
2190 let mut rng = Rng::new(42);
2191 assert!(EnemyScaler::elite_prefix(10, &mut rng).is_none());
2192 assert!(EnemyScaler::elite_prefix(49, &mut rng).is_none());
2193 let mut found = false;
2195 for _ in 0..20 {
2196 if EnemyScaler::elite_prefix(50, &mut rng).is_some() {
2197 found = true;
2198 break;
2199 }
2200 }
2201 assert!(found, "Floor 50+ should eventually produce an elite prefix");
2202 }
2203
2204 #[test]
2205 fn test_fog_of_war_initial_unseen() {
2206 let fog = FogOfWar::new(10, 10);
2207 for y in 0..10i32 {
2208 for x in 0..10i32 {
2209 assert_eq!(fog.get(x, y), Visibility::Unseen);
2210 }
2211 }
2212 }
2213
2214 #[test]
2215 fn test_fog_of_war_reveal() {
2216 let config = FloorConfig::for_floor(1);
2217 let floor = FloorGenerator::generate(&config, 42);
2218 let mut fog = FogOfWar::new(floor.map.width, floor.map.height);
2219 let center = floor.map.player_start;
2220 fog.reveal_around(center, 5, &floor.map);
2221 assert_eq!(fog.get(center.x, center.y), Visibility::Visible);
2222 assert!(fog.explored_count() > 0);
2223 }
2224
2225 #[test]
2226 fn test_fog_fade_visible_to_seen() {
2227 let mut fog = FogOfWar::new(5, 5);
2228 fog.set(2, 2, Visibility::Visible);
2229 assert_eq!(fog.get(2, 2), Visibility::Visible);
2230 fog.fade_visible();
2231 assert_eq!(fog.get(2, 2), Visibility::Seen);
2232 }
2233
2234 #[test]
2235 fn test_tile_properties() {
2236 assert!(Tile::Floor.is_walkable());
2237 assert!(!Tile::Wall.is_walkable());
2238 assert!(Tile::Lava.is_walkable());
2239 assert!(Tile::Lava.properties().damage_on_step.is_some());
2240 assert!(Tile::Wall.blocks_sight());
2241 assert!(!Tile::Floor.blocks_sight());
2242 assert!(Tile::Door.blocks_sight());
2243 assert_eq!(Tile::Water.properties().element, Some(Element::Ice));
2244 }
2245
2246 #[test]
2247 fn test_floor_config_auto_generation() {
2248 let c1 = FloorConfig::for_floor(1);
2249 assert_eq!(c1.biome, FloorBiome::Ruins);
2250 assert!(!c1.boss_floor);
2251
2252 let c10 = FloorConfig::for_floor(10);
2253 assert!(c10.boss_floor);
2254
2255 let c100 = FloorConfig::for_floor(100);
2256 assert!(c100.boss_floor);
2257 assert_eq!(c100.biome, FloorBiome::Cathedral);
2258 }
2259
2260 #[test]
2261 fn test_floor_theme_ranges() {
2262 let t5 = FloorTheme::for_floor(5);
2263 assert_eq!(t5.biome, FloorBiome::Ruins);
2264 assert!(!t5.traps_enabled);
2265
2266 let t20 = FloorTheme::for_floor(20);
2267 assert_eq!(t20.biome, FloorBiome::Crypt);
2268 assert!(t20.traps_enabled);
2269
2270 let t40 = FloorTheme::for_floor(40);
2271 assert_eq!(t40.biome, FloorBiome::Forge);
2272
2273 let t60 = FloorTheme::for_floor(60);
2274 assert_eq!(t60.biome, FloorBiome::Void);
2275
2276 let t80 = FloorTheme::for_floor(80);
2277 assert_eq!(t80.biome, FloorBiome::Abyss);
2278
2279 let t100 = FloorTheme::for_floor(100);
2280 assert_eq!(t100.biome, FloorBiome::Cathedral);
2281 }
2282
2283 #[test]
2284 fn test_biome_properties_populated() {
2285 for biome in &[
2286 FloorBiome::Ruins, FloorBiome::Crypt, FloorBiome::Library,
2287 FloorBiome::Forge, FloorBiome::Garden, FloorBiome::Void,
2288 FloorBiome::Chaos, FloorBiome::Abyss, FloorBiome::Cathedral,
2289 FloorBiome::Laboratory,
2290 ] {
2291 let props = biome.properties();
2292 assert!(props.ambient_light >= 0.0 && props.ambient_light <= 1.0);
2293 assert!(!props.flavor_text.is_empty());
2294 assert!(!props.music_vibe.is_empty());
2295 }
2296 }
2297
2298 #[test]
2299 fn test_dungeon_manager_lifecycle() {
2300 let mut mgr = DungeonManager::new(42);
2301 assert!(mgr.current_floor().is_none());
2302
2303 mgr.start();
2304 assert_eq!(mgr.floor_number(), 1);
2305 assert!(mgr.current_floor().is_some());
2306
2307 mgr.descend();
2308 assert_eq!(mgr.floor_number(), 2);
2309 assert!(mgr.current_floor().is_some());
2310
2311 mgr.ascend();
2312 assert_eq!(mgr.floor_number(), 1);
2313 assert!(mgr.current_floor().is_some());
2314 }
2315
2316 #[test]
2317 fn test_dungeon_manager_backtrack_preserves_floor() {
2318 let mut mgr = DungeonManager::new(42);
2319 mgr.start();
2320
2321 let f1_rooms = mgr.current_floor().unwrap().map.rooms.len();
2323
2324 mgr.descend();
2325 mgr.ascend();
2326
2327 let f1_rooms_after = mgr.current_floor().unwrap().map.rooms.len();
2329 assert_eq!(f1_rooms, f1_rooms_after);
2330 }
2331
2332 #[test]
2333 fn test_minimap_render() {
2334 let config = FloorConfig::for_floor(1);
2335 let floor = FloorGenerator::generate(&config, 42);
2336 let mut fog = FogOfWar::new(floor.map.width, floor.map.height);
2337 fog.reveal_around(floor.map.player_start, 10, &floor.map);
2338 let glyphs = Minimap::render_minimap(&floor.map, floor.map.player_start, &fog);
2339 assert!(!glyphs.is_empty(), "Minimap should produce glyphs");
2340 let has_player = glyphs.iter().any(|g| g.ch == '@');
2341 assert!(has_player, "Minimap should contain player marker");
2342 }
2343
2344 #[test]
2345 fn test_room_shape_carve() {
2346 let rect = IRect::new(2, 2, 8, 8);
2347 let mut tiles = vec![Tile::Wall; 20 * 20];
2348 let mut rng = Rng::new(42);
2349 RoomShape::Rectangle.carve(&rect, &mut tiles, 20, &mut rng);
2350 let center_idx = 6 * 20 + 6;
2352 assert_eq!(tiles[center_idx], Tile::Floor);
2353 }
2354
2355 #[test]
2356 fn test_boss_floor_100_has_algorithm_reborn() {
2357 let config = FloorConfig::for_floor(100);
2358 let floor = FloorGenerator::generate(&config, 42);
2359 let boss_room = floor.map.rooms.iter().find(|r| r.room_type == RoomType::Boss);
2360 assert!(boss_room.is_some(), "Floor 100 should have a boss room");
2361 if let Some(br) = boss_room {
2362 let has_algo = br.enemies.iter().any(|e| e.name == "The Algorithm Reborn");
2363 assert!(has_algo, "Floor 100 boss should be The Algorithm Reborn");
2364 }
2365 }
2366
2367 #[test]
2368 fn test_floor_map_walkable_neighbors() {
2369 let config = FloorConfig::for_floor(1);
2370 let floor = FloorGenerator::generate(&config, 42);
2371 let neighbors = floor.map.walkable_neighbors(floor.map.player_start);
2373 assert!(!neighbors.is_empty(), "Player start should have walkable neighbors");
2374 }
2375
2376 #[test]
2377 fn test_corridor_style_variants() {
2378 let styles = [CorridorStyle::Straight, CorridorStyle::Winding, CorridorStyle::Organic];
2380 let mut tiles = vec![Tile::Wall; 50 * 50];
2381 let mut rng = Rng::new(42);
2382 let from = IVec2::new(5, 5);
2383 let to = IVec2::new(20, 20);
2384 for style in &styles {
2385 let mut t = tiles.clone();
2386 let path = style.carve_corridor(from, to, &mut t, 50, 50, &mut rng);
2387 assert!(!path.is_empty(), "Corridor {:?} should produce a path", style);
2388 }
2389 }
2390}