feat: m2 etagengenerierung — prefabs, floors, korridore, abstieg und sieg
Der handgebaute Testraum ist weg; Etagen entstehen aus Daten. - game/rng.rs: xorshift64*, Seed im Game (Ticket 14) - game/generate.rs: freie Platzierung, Pflichtraeume zuerst, Kette i->i-1 fuer strukturelle Erreichbarkeit, acht Orientierungen - data/: [[prefabs]] und [[floors]] als neunte und zehnte Registry, Ladepruefungen fuer rechteckige Layouts und Pflichtraum-Flaeche - Descend plus Siegbedingung: keine [[floors]] mit der naechsten Nummer heisst gewonnen — die Zahl 5 steht nirgends im Code - GameOver-Screen, mitlaufender Bildausschnitt im Grid Befund: Korridore stanzten 2340 Loecher in gezeichnete Waende, weil sie AUF der Tuer starteten. Erst Schritt nach aussen, dann L-Form, sonst Wegsuche ueber den freien Raum — die kann eine Raumwand nicht beruehren. Der Test zaehlt jetzt null. 21 Tests.
This commit is contained in:
453
src/game/generate.rs
Normal file
453
src/game/generate.rs
Normal file
@@ -0,0 +1,453 @@
|
||||
//! 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::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<char>,
|
||||
}
|
||||
|
||||
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<Placed> = 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<u32> = 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();
|
||||
|
||||
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)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rooms.push(Room {
|
||||
prefab: p.prefab.clone(),
|
||||
rect: p.rect,
|
||||
enemy_spawns,
|
||||
loot,
|
||||
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<Placed>) {
|
||||
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<Vec<(u16, u16)>> {
|
||||
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<Option<(u16, u16)>> = 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user