Toeten gibt Erfahrung (exp_reward stand seit M3 ungelesen an jedem Gegner), give_exp als zwoelfte Aktion fuer spaetere Quests. Aufstieg in einer Schleife am Zugende, ein Punkt je Stufe, keine Obergrenze. Verteilt wird im Charakterbogen — kein zwoelfter Screen. AssignPoints nimmt alle Punkte auf einmal und kostet damit einen Zug. luck wirkt jetzt: eine Chance auf einen Zusatzzug auf jeder Beutetabelle. Drei Befunde: - strength und constitution las niemand, zwei der fuenf verteilbaren Grundwerte waren Fallen. Zwei balance-Knoepfe rechnen sie beim Verteilen um. - Rng::chance(0.0) verbrauchte eine Zahl und verschob den Zufallsstrom. - Cheat::ClearRoom ging am Todesweg vorbei.
500 lines
16 KiB
Rust
500 lines
16 KiB
Rust
//! 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<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();
|
||
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<u32> = 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<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
|
||
}
|
||
}
|