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:
2026-08-12 16:14:23 +02:00
parent 3714bad313
commit f998e55210
14 changed files with 1121 additions and 82 deletions

453
src/game/generate.rs Normal file
View 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, &reg, &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
}
}