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

@@ -4,14 +4,28 @@ pub mod attributes;
pub mod command;
pub mod creature;
pub mod floor;
pub mod generate;
pub mod rng;
pub use attributes::{Attribute, Modifier};
pub use command::{Command, Direction, Outcome};
pub use creature::{Creature, CreatureRef, ItemInstance};
pub use floor::{Floor, Tile};
pub use rng::Rng;
use crate::data::Registries;
/// Wie ein Lauf endet (Ticket 10/14). Sieg ueber das **Fehlen** der naechsten
/// Etage — es gibt keine Zahl „5" im Code.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunOutcome {
/// TODO(M3): wird gesetzt, sobald es Kampf gibt und der Held sterben kann.
/// Der `GameOver`-Screen zeigt beide Enden bereits.
#[allow(dead_code)]
Died,
Won,
}
pub struct Game {
pub player: Creature,
/// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf.
@@ -19,27 +33,36 @@ pub struct Game {
pub floor: Floor,
pub player_pos: (u16, u16),
pub turn: u64,
/// Im Speicherstand, damit Nachladen nicht neu wuerfelt (Ticket 14).
pub rng: Rng,
pub over: Option<RunOutcome>,
/// Letzte Meldung fuer die Statuszeile.
pub last: String,
// TODO(M3): `events: Vec<Event>` (Warteschlange aus Ticket 08) und der
// xorshift-Seed aus Ticket 14. Beide brauchen erst einen Trigger
// bzw. einen Wurf, um etwas zu tun.
// TODO(M3): `events: Vec<Event>` — die Warteschlange aus Ticket 08.
// TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
// `Game`, nicht am `Creature`).
}
impl Game {
pub fn new(player: Creature) -> Self {
let floor = Floor::test_room();
/// Erzeugt die erste Etage. Schlaegt fehl, wenn keine `[[floors]]` mit
/// `number = 1` existiert — dann ist `core` kaputt, und das faellt beim
/// `cargo test` auf (Ticket 04).
pub fn new(player: Creature, reg: &Registries, mut rng: Rng) -> Option<Self> {
let def = reg.floor_number(1)?;
let mut floor = generate::generate(def, reg, &mut rng);
let player_pos = floor.entrance();
Self {
floor.player_room = floor.room_at(player_pos.0, player_pos.1);
Some(Self {
player,
enemies: Vec::new(),
floor,
player_pos,
turn: 0,
rng,
over: None,
last: String::from("—"),
}
})
}
pub fn creature(&self, who: CreatureRef) -> &Creature {
@@ -86,13 +109,14 @@ impl Game {
}
/// Der **eine** Eingang, an dem ein Zug endet (Ticket 11).
pub fn execute(&mut self, cmd: Command, _reg: &Registries) -> Outcome {
pub fn execute(&mut self, cmd: Command, reg: &Registries) -> Outcome {
let outcome = match cmd {
Command::Move(dir) => self.step(dir),
Command::Wait => {
self.last = String::from("gewartet");
Outcome::TurnPassed
}
Command::Descend => self.descend(reg),
};
// Nur ausgefuehrte Kommandos kosten einen Zug (Ticket 12).
@@ -119,12 +143,51 @@ impl Game {
}
self.player_pos = (nx, ny);
// Korridore gehoeren zu keinem Raum — `None` ist ein echter Zustand.
let room = self.floor.room_at(nx, ny);
if room != self.floor.player_room {
self.floor.player_room = room;
// TODO(M3): hier feuert `room_entered` (Ticket 08/10) und schiebt
// die Gegner des Raums nach `Game.enemies`.
}
self.last = match self.floor.tile(nx, ny) {
Tile::Stairs => String::from("Eine Treppe führt nach unten"),
_ => format!("{},{}", nx, ny),
Tile::Stairs => String::from("Eine Treppe führt nach unten — '>' zum Absteigen"),
_ => self.room_label(),
};
Outcome::TurnPassed
}
fn room_label(&self) -> String {
match self.floor.player_room {
Some(i) => self.floor.rooms[i].prefab.clone(),
None => String::from("Korridor"),
}
}
/// Ticket 10: Der Abstieg von der **letzten definierten Etage gewinnt den
/// Lauf**. Es gibt keine Zahl „5" im Code — die Engine sucht `[[floors]]`
/// mit `number = aktuell + 1`, und findet sie keine, ist Schluss. Ein Mod,
/// der Etage 6 ergaenzt, verlaengert das Spiel ohne eine Zeile Engine-Code.
fn descend(&mut self, reg: &Registries) -> Outcome {
if self.floor.tile(self.player_pos.0, self.player_pos.1) != Tile::Stairs {
return Outcome::Rejected("Hier ist keine Treppe");
}
let Some(def) = reg.floor_number(self.floor.number + 1) else {
self.over = Some(RunOutcome::Won);
self.last = String::from("Du steigst ans Licht.");
return Outcome::TurnPassed;
};
self.floor = generate::generate(def, reg, &mut self.rng);
self.player_pos = self.floor.entrance();
self.floor.player_room = self.floor.room_at(self.player_pos.0, self.player_pos.1);
self.enemies.clear();
self.last = format!("Etage {}", self.floor.number);
Outcome::TurnPassed
}
}
#[cfg(test)]
@@ -138,7 +201,38 @@ mod tests {
hero.options = vec![String::from("core:dwarf"), String::from("core:warrior")];
hero.equipment
.insert(String::from("core:hand"), ItemInstance::new("core:iron_sword"));
(Game::new(hero), reg)
let game = Game::new(hero, &reg, Rng::new(99)).expect("Etage 1 muss existieren");
(game, reg)
}
/// Ticket 10: Sieg ueber das **Fehlen** der naechsten Etage. Wer bis unter
/// die letzte definierte Etage steigt, hat gewonnen — ohne dass irgendwo
/// eine Fuenf im Code steht.
#[test]
fn abstieg_unter_die_letzte_etage_gewinnt() {
let (mut game, reg) = zwergenkrieger();
let letzte = reg.floors.values().map(|f| f.number).max().expect("Etagen da");
for _ in 0..letzte {
let treppe = (0..game.floor.height)
.flat_map(|y| (0..game.floor.width).map(move |x| (x, y)))
.find(|&(x, y)| game.floor.tile(x, y) == Tile::Stairs)
.expect("jede Etage hat eine Treppe (Pflichtraum)");
game.player_pos = treppe;
assert!(matches!(game.execute(Command::Descend, &reg), Outcome::TurnPassed));
}
assert_eq!(game.over, Some(RunOutcome::Won));
}
#[test]
fn absteigen_ohne_treppe_kostet_keinen_zug() {
let (mut game, reg) = zwergenkrieger();
game.player_pos = game.floor.entrance();
assert!(matches!(game.execute(Command::Descend, &reg), Outcome::Rejected(_)));
assert_eq!(game.turn, 0);
}
/// Der Zielsatz von Meilenstein 1 als Behauptung: ein Wert, der aus einem
@@ -177,6 +271,7 @@ mod tests {
#[test]
fn wand_kostet_keinen_zug() {
let (mut game, reg) = zwergenkrieger();
// Die Ecke oben links ist auf jeder erzeugten Etage Fels.
game.player_pos = (1, 1);
assert!(matches!(