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

View File

@@ -92,6 +92,62 @@ pub struct CharacterOption {
// Startausruestung brauchen Kampf bzw. Inventar.
}
/// Ein von Hand gezeichneter Raum als ASCII-Block (Ticket 10).
#[derive(Debug, Clone, Deserialize)]
pub struct Prefab {
pub id: String,
#[serde(default)]
pub tags: Vec<String>,
pub layout: String,
/// Ersetzt die Etagentabelle fuer die `l`-Plaetze dieses Raums (Ticket 16).
#[serde(default)]
pub loot: Option<String>,
#[serde(default)]
pub overrides: Option<String>,
}
impl Prefab {
/// Zeilen ohne fuehrende/abschliessende Leerzeile. TOML-Mehrzeiler beginnen
/// mit einem Zeilenumbruch, sobald `"""` allein auf der Zeile steht.
pub fn rows(&self) -> Vec<&str> {
self.layout.lines().filter(|l| !l.trim().is_empty()).collect()
}
pub fn size(&self) -> (u16, u16) {
let rows = self.rows();
(rows.first().map_or(0, |r| r.chars().count() as u16), rows.len() as u16)
}
}
/// Gewichteter Verweis auf eine Prefab-**Tag**-Gruppe. Tags statt IDs, sonst
/// muesste jede Etagentabelle jedes neue Modder-Prefab einzeln aufzaehlen.
#[derive(Debug, Clone, Deserialize)]
pub struct WeightedTag {
pub tag: String,
#[serde(default = "one")]
pub weight: u32,
}
/// Eine Ebene des Dungeons und zugleich ihr Bauplan (Ticket 10).
#[derive(Debug, Clone, Deserialize)]
pub struct FloorDef {
pub id: String,
pub number: u8,
/// Felder, nicht Zellen.
pub size: (u16, u16),
/// Zielzahl inklusive Pflichtraeume.
pub rooms: usize,
/// Tags, die garantiert vorkommen — zuerst platziert, das *ist* die Garantie.
#[serde(default)]
pub required: Vec<String>,
#[serde(default)]
pub prefabs: Vec<WeightedTag>,
#[serde(default)]
pub overrides: Option<String>,
// TODO(M3): `creatures` — welche Gegner die `e`-Plaetze fuellen.
// TODO(M4): `loot` — die Beutetabelle fuer die `l`-Plaetze (Ticket 16).
}
/// Alle geladenen Definitionen. Liegt **neben** `Game`, nicht darin — als Feld
/// kollidierte `&mut game.player` mit `&game.registries` bei jedem
/// Methodenaufruf (Ticket 07).
@@ -101,8 +157,10 @@ pub struct Registries {
pub slots: HashMap<String, Slot>,
pub categories: HashMap<String, Category>,
pub options: HashMap<String, CharacterOption>,
// TODO(M2+): `affixes`, `abilities`, `quests`, `creatures`, `prefabs`,
// `floors`, `loot_tables`, `scripts` — zwoelf sind es am Ende.
pub prefabs: HashMap<String, Prefab>,
pub floors: HashMap<String, FloorDef>,
// TODO(M3+): `affixes`, `abilities`, `quests`, `creatures`, `loot_tables`,
// `scripts` — zwoelf sind es am Ende.
}
impl Registries {
@@ -113,4 +171,21 @@ impl Registries {
v.sort_by_key(|s| (s.order, s.id.clone()));
v
}
/// Prefabs mit einem bestimmten Tag, in stabiler Reihenfolge — sonst
/// haengt die Etage von der HashMap-Iterationsreihenfolge ab und derselbe
/// Seed erzeugt zwei verschiedene Dungeons.
pub fn prefabs_tagged(&self, tag: &str) -> Vec<&Prefab> {
let mut v: Vec<&Prefab> =
self.prefabs.values().filter(|p| p.tags.iter().any(|t| t == tag)).collect();
v.sort_by(|a, b| a.id.cmp(&b.id));
v
}
pub fn floor_number(&self, number: u8) -> Option<&FloorDef> {
// Stabil, falls zwei Mods dieselbe Nummer vergeben: die kleinere ID.
// Ohne das entscheidet die HashMap-Reihenfolge, und derselbe Seed
// erzeugte zwei verschiedene Dungeons.
self.floors.values().filter(|f| f.number == number).min_by_key(|f| &f.id)
}
}