//! Etagengenerator — Ticket 10. //! //! Freie Platzierung mit L-Korridoren. Verbunden wird **Raum `i` mit Raum //! `i−1`**, weil eine Kette Erreichbarkeit *strukturell* garantiert: Haengt //! jeder Raum am vorigen, ist jeder von jedem erreichbar — ohne Flutfuellung //! und ohne Reparaturschleife. use crate::data::{FloorDef, Prefab, Registries}; use super::creature::Creature; use super::floor::{Floor, Rect, Room, Tile}; use super::rng::Rng; /// Ein Prefab-Layout in einer der acht Ausrichtungen. struct Stamp { w: u16, h: u16, cells: Vec, } impl Stamp { fn from(prefab: &Prefab) -> Self { let rows = prefab.rows(); let w = rows[0].chars().count() as u16; let h = rows.len() as u16; Self { w, h, cells: rows.iter().flat_map(|r| r.chars()).collect() } } fn at(&self, x: u16, y: u16) -> char { self.cells[y as usize * self.w as usize + x as usize] } /// 90° im Uhrzeigersinn: transponieren und Zeilen umkehren. fn rotated(&self) -> Self { let mut cells = Vec::with_capacity(self.cells.len()); for x in 0..self.w { for y in (0..self.h).rev() { cells.push(self.at(x, y)); } } Self { w: self.h, h: self.w, cells } } fn mirrored(&self) -> Self { let mut cells = Vec::with_capacity(self.cells.len()); for y in 0..self.h { for x in (0..self.w).rev() { cells.push(self.at(x, y)); } } Self { w: self.w, h: self.h, cells } } /// Acht Varianten je Prefab — achtmal so viel Inhalt aus derselben /// Handarbeit (Ticket 10). fn oriented(prefab: &Prefab, rng: &mut Rng) -> Self { let mut s = Stamp::from(prefab); if rng.chance(0.5) { s = s.mirrored(); } for _ in 0..rng.below(4) { s = s.rotated(); } s } } struct Placed { prefab: String, rect: Rect, stamp: Stamp, } pub fn generate(def: &FloorDef, reg: &Registries, rng: &mut Rng) -> Floor { let (width, height) = def.size; let mut placed: Vec = Vec::new(); // Pflichtraeume zuerst auf die leere Flaeche — **das ist die Garantie**. for tag in &def.required { let candidates = reg.prefabs_tagged(tag); if candidates.is_empty() { continue; // beim Laden bereits gewarnt (Ticket 10) } let prefab = candidates[rng.below(candidates.len())]; try_place(prefab, width, height, rng, &mut placed); } // Danach gewichtet auffuellen. Raeume, die nicht mehr passen, entfallen // einfach — das ist der Normalfall, kein Fehler. let weights: Vec = def.prefabs.iter().map(|p| p.weight).collect(); let mut attempts = 0; while placed.len() < def.rooms && attempts < def.rooms * 20 { attempts += 1; let Some(i) = rng.weighted(&weights) else { break }; let candidates = reg.prefabs_tagged(&def.prefabs[i].tag); if candidates.is_empty() { continue; } let prefab = candidates[rng.below(candidates.len())]; try_place(prefab, width, height, rng, &mut placed); } // Stempeln let mut tiles = vec![Tile::Wall; width as usize * height as usize]; let mut rooms = Vec::new(); for p in &placed { let mut enemy_spawns = Vec::new(); let mut loot = Vec::new(); let mut npc_plaetze = Vec::new(); for y in 0..p.stamp.h { for x in 0..p.stamp.w { let (gx, gy) = (p.rect.x + x, p.rect.y + y); let c = p.stamp.at(x, y); let tile = match c { '#' => Tile::Wall, '+' => Tile::Door, '>' => Tile::Stairs, '<' => Tile::Entrance, _ => Tile::Floor, }; tiles[gy as usize * width as usize + gx as usize] = tile; match c { 'e' => enemy_spawns.push((gx, gy)), 'l' => loot.push((gx, gy)), 'n' => npc_plaetze.push((gx, gy)), _ => {} } } } // Ein `e` sagt „hier steht ein Gegner", **nicht welcher** — das // entscheidet die Etage (Ticket 10). Nur so laesst sich derselbe Raum // auf Etage 1 und Etage 5 verwenden. let gewichte: Vec = def.creatures.iter().map(|c| c.weight).collect(); let enemies = enemy_spawns .iter() .filter_map(|_| { let i = rng.weighted(&gewichte)?; let cdef = reg.creatures.get(&def.creatures[i].id)?; Some(Creature::from_def(cdef)) }) .collect(); // Ein Prefab darf ueber sein optionales `loot`-Feld die Etagentabelle // ersetzen — sonst ist ein Schatzkammer-Raum nur ein Raum mit mehr // `l`-Zeichen (Ticket 16). let loot_table = reg .prefabs .get(&p.prefab) .and_then(|pf| pf.loot.clone()) .or_else(|| def.loot.clone()) .unwrap_or_default(); // Der i-te `n`-Platz bekommt den i-ten Eintrag aus `prefab.npcs` — // Positionsbindung statt eines zweiten Markers je NPC. let npcs = npc_plaetze .into_iter() .enumerate() .filter_map(|(i, pos)| { let id = reg.prefabs.get(&p.prefab)?.npcs.get(i)?.clone(); let stock = reg .npcs .get(&id) .and_then(|n| n.shop.as_deref()) .map(|t| crate::game::loot::roll(t, 0.0, reg, rng)) .unwrap_or_default(); Some(super::floor::NpcInstance { pos, def: id, stock }) }) .collect(); rooms.push(Room { prefab: p.prefab.clone(), rect: p.rect, enemy_spawns, enemies, loot_table, loot, npcs, cleared: false, }); } // Kette: Raum i an Raum i-1. for i in 1..placed.len() { connect(&placed, i - 1, i, width, height, &mut tiles); } Floor { number: def.number, width, height, tiles, rooms, player_room: None } } fn try_place(prefab: &Prefab, width: u16, height: u16, rng: &mut Rng, placed: &mut Vec) { for _ in 0..60 { let stamp = Stamp::oriented(prefab, rng); if stamp.w + 2 >= width || stamp.h + 2 >= height { return; // passt grundsaetzlich nicht } let x = 1 + rng.below((width - stamp.w - 2) as usize) as u16; let y = 1 + rng.below((height - stamp.h - 2) as usize) as u16; let rect = Rect { x, y, w: stamp.w, h: stamp.h }; // Ein Feld Rand, damit zwei Raeume nicht Wand an Wand kleben. if placed.iter().any(|p| p.rect.touches(&rect)) { continue; } placed.push(Placed { prefab: prefab.id.clone(), rect, stamp }); return; } } /// Tueren eines platzierten Raums, in Weltkoordinaten. fn doors(p: &Placed) -> Vec<(u16, u16)> { let mut out = Vec::new(); for y in 0..p.stamp.h { for x in 0..p.stamp.w { if p.stamp.at(x, y) == '+' { out.push((p.rect.x + x, p.rect.y + y)); } } } out } /// Das Feld **direkt ausserhalb** einer Tuer. Ohne diesen Schritt beginnt der /// Korridor *auf* der Tuer — und weil eine Tuer auf der Raumwand liegt, graebt /// der waagerechte Abschnitt die ganze Wandreihe auf. Der Test /// `korridore_stanzen_keine_loecher_in_raumwaende` hat genau das gefunden. fn door_exit(p: &Placed, (x, y): (u16, u16)) -> Option<(u16, u16)> { let r = p.rect; if y == r.y { return y.checked_sub(1).map(|ny| (x, ny)); } if y == r.y + r.h - 1 { return Some((x, y + 1)); } if x == r.x { return x.checked_sub(1).map(|nx| (nx, y)); } if x == r.x + r.w - 1 { return Some((x + 1, y)); } None // Tuer mitten im Raum ergibt keinen Ausgang } fn connect(all: &[Placed], ia: usize, ib: usize, width: u16, height: u16, tiles: &mut [Tile]) { let exits = |i: usize| -> Vec<(u16, u16)> { doors(&all[i]).into_iter().filter_map(|d| door_exit(&all[i], d)).collect() }; let (ea, eb) = (exits(ia), exits(ib)); if ea.is_empty() || eb.is_empty() { // Prefab ohne Tuer laesst sich nicht anschliessen. Mitte gegen Mitte // waere die Alternative — und wuerde genau die Loecher stanzen, die // Ticket 10 verhindern wollte. return; } // Alle Tuerpaare, kuerzeste zuerst, jeweils beide L-Varianten. let mut paare: Vec<((u16, u16), (u16, u16))> = ea.iter().flat_map(|s| eb.iter().map(move |e| (*s, *e))).collect(); paare.sort_by_key(|(s, e)| s.0.abs_diff(e.0) as u32 + s.1.abs_diff(e.1) as u32); let frei = |path: &[(u16, u16)]| { path.iter().all(|&(x, y)| { x < width && y < height && !all.iter().any(|p| p.rect.contains(x, y)) }) }; // Erst die L-Form: zwei Segmente sehen wie ein Korridor aus, nicht wie ein // Irrgang. Wenn eine davon frei ist, gewinnt sie. for (start, end) in &paare { for horizontal_first in [true, false] { let path = l_path(*start, *end, horizontal_first); if frei(&path) { carve(&path, width, tiles); return; } } } // Sonst Wegsuche **ueber den freien Raum**. Sie kann eine Raumwand gar nicht // beruehren, weil Raumfelder nicht im Suchraum liegen — damit haelt die // Zusage aus Ticket 10 („keine Loecher in gezeichnete Waende") statt nur // wahrscheinlich zu sein. Zwei L-Varianten senkten sie nur. for (start, end) in &paare { if let Some(path) = bfs(*start, *end, width, height, all) { carve(&path, width, tiles); return; } } // Kein Weg: dann bleibt der Raum unverbunden, und // `alles_begehbare_haengt_zusammen` schlaegt an. Laut statt still. } /// Kuerzester Weg durch Felder, die in **keinem** Raum liegen. fn bfs( start: (u16, u16), end: (u16, u16), width: u16, height: u16, rooms: &[Placed], ) -> Option> { let idx = |x: u16, y: u16| y as usize * width as usize + x as usize; let blocked = |x: u16, y: u16| rooms.iter().any(|p| p.rect.contains(x, y)); if start.0 >= width || start.1 >= height || blocked(start.0, start.1) { return None; } let mut prev: Vec> = vec![None; width as usize * height as usize]; let mut seen = vec![false; width as usize * height as usize]; let mut queue = std::collections::VecDeque::from([start]); seen[idx(start.0, start.1)] = true; while let Some((x, y)) = queue.pop_front() { if (x, y) == end { let mut path = vec![(x, y)]; let mut cur = (x, y); while let Some(p) = prev[idx(cur.0, cur.1)] { path.push(p); cur = p; } return Some(path); } let nachbarn = [ (x.wrapping_sub(1), y), (x + 1, y), (x, y.wrapping_sub(1)), (x, y + 1), ]; for (nx, ny) in nachbarn { if nx >= width || ny >= height || seen[idx(nx, ny)] || blocked(nx, ny) { continue; } seen[idx(nx, ny)] = true; prev[idx(nx, ny)] = Some((x, y)); queue.push_back((nx, ny)); } } None } fn carve(path: &[(u16, u16)], width: u16, tiles: &mut [Tile]) { for &(x, y) in path { let idx = y as usize * width as usize + x as usize; if idx < tiles.len() && tiles[idx] == Tile::Wall { tiles[idx] = Tile::Floor; } } } fn l_path(from: (u16, u16), to: (u16, u16), horizontal_first: bool) -> Vec<(u16, u16)> { let mut path = Vec::new(); let (x0, y0) = from; let (x1, y1) = to; let push_h = |y: u16, path: &mut Vec<(u16, u16)>| { for x in x0.min(x1)..=x0.max(x1) { path.push((x, y)); } }; let push_v = |x: u16, path: &mut Vec<(u16, u16)>| { for y in y0.min(y1)..=y0.max(y1) { path.push((x, y)); } }; if horizontal_first { push_h(y0, &mut path); push_v(x1, &mut path); } else { push_v(x0, &mut path); push_h(y1, &mut path); } path } #[cfg(test)] mod tests { use super::*; use crate::data::load_mods; fn erzeuge(seed: u64, nummer: u8) -> Floor { let (reg, _) = load_mods().expect("core muss laden"); let def = reg.floor_number(nummer).expect("Etage existiert"); generate(def, ®, &mut Rng::new(seed)) } #[test] fn derselbe_seed_erzeugt_dieselbe_etage() { let a = erzeuge(1234, 1); let b = erzeuge(1234, 1); assert_eq!(a.tiles, b.tiles, "sonst waere der Seed im Save wertlos"); assert_eq!(a.rooms.len(), b.rooms.len()); } #[test] fn pflichtraeume_sind_da() { // Pflichtraeume kommen zuerst auf die leere Flaeche — sie muessen // ueber alle Seeds hinweg auftauchen. for seed in 1..40u64 { let f = erzeuge(seed, 1); let hat = |tag: &str| f.rooms.iter().any(|r| r.prefab.contains(tag)); assert!(hat("entrance_hall"), "seed {seed}: kein Eingang"); assert!(hat("stair_room"), "seed {seed}: keine Treppe"); assert!(hat("market"), "seed {seed}: kein Händler"); } } #[test] fn raeume_ueberlappen_nicht() { for seed in 1..30u64 { let f = erzeuge(seed, 3); for (i, a) in f.rooms.iter().enumerate() { for b in f.rooms.iter().skip(i + 1) { assert!(!a.rect.touches(&b.rect), "seed {seed}: {} trifft {}", a.prefab, b.prefab); } } } } /// Die Kette garantiert Erreichbarkeit strukturell — hier wird nachgezaehlt, /// ob das auch nach dem Stempeln stimmt. #[test] fn alles_begehbare_haengt_zusammen() { for seed in 1..30u64 { let f = erzeuge(seed, 2); let start = f.entrance(); let erreichbar = flood(&f, start); let begehbar = f.tiles.iter().filter(|t| **t != Tile::Wall).count(); assert_eq!( erreichbar, begehbar, "seed {seed}: {} von {begehbar} Feldern erreichbar", erreichbar ); } } /// Ticket 10 hat Tueren ausdruecklich gemacht, damit der Generator **keine /// Loecher in eine von Hand gezeichnete Wand stanzt**. Der Rand eines Raums /// besteht im Prefab nur aus `#` und `+` — findet sich dort Boden, hat ein /// Korridor durchgegraben. #[test] fn korridore_stanzen_keine_loecher_in_raumwaende() { let mut durchgegraben = 0; for seed in 1..60u64 { let f = erzeuge(seed, 3); for room in &f.rooms { let r = room.rect; let rand = (r.x..r.x + r.w) .flat_map(|x| [(x, r.y), (x, r.y + r.h - 1)]) .chain((r.y..r.y + r.h).flat_map(|y| [(r.x, y), (r.x + r.w - 1, y)])); for (x, y) in rand { if f.tile(x, y) == Tile::Floor { durchgegraben += 1; } } } } assert_eq!( durchgegraben, 0, "{durchgegraben} Wandfelder wurden durchgegraben — der Rueckfallpfad in \ `connect` greift haeufiger als gedacht und braucht eine echte Wegsuche" ); } fn flood(f: &Floor, start: (u16, u16)) -> usize { let mut seen = vec![false; f.tiles.len()]; let mut stack = vec![start]; let mut n = 0; while let Some((x, y)) = stack.pop() { let idx = y as usize * f.width as usize + x as usize; if seen[idx] || f.tile(x, y) == Tile::Wall { continue; } seen[idx] = true; n += 1; if x > 0 { stack.push((x - 1, y)); } if y > 0 { stack.push((x, y - 1)); } if x + 1 < f.width { stack.push((x + 1, y)); } if y + 1 < f.height { stack.push((x, y + 1)); } } n } }