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

53
mods/core/floors.toml Normal file
View File

@@ -0,0 +1,53 @@
# Etagen sind Registry-Eintraege (Ticket 10). Ausgewaehlt wird durchgehend
# ueber TAGS, nie ueber IDs — sonst kann ein Mod keinen eigenen Eingangsraum
# beisteuern, ohne die ganze Tabelle zu ueberschreiben.
#
# `core:merchant` steht unter `required`, nicht unter `prefabs`: Ticket 07 hat
# Backtracking ausgeschlossen, der Haendler muss also garantiert auftauchen.
#
# Es gibt keine Zahl "5" im Code — findet die Engine keine Etage mit der
# naechsten Nummer, ist der Lauf gewonnen.
[[floors]]
id = "floor_1"
number = 1
size = [72, 34]
rooms = 7
required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 } ]
[[floors]]
id = "floor_2"
number = 2
size = [72, 34]
rooms = 8
required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 },
{ tag = "core:treasure", weight = 2 } ]
[[floors]]
id = "floor_3"
number = 3
size = [80, 38]
rooms = 9
required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 },
{ tag = "core:treasure", weight = 3 } ]
[[floors]]
id = "floor_4"
number = 4
size = [80, 38]
rooms = 10
required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 },
{ tag = "core:treasure", weight = 3 } ]
[[floors]]
id = "floor_5"
number = 5
size = [88, 40]
rooms = 11
required = ["core:entrance", "core:stairs", "core:merchant"]
prefabs = [ { tag = "core:normal", weight = 10 },
{ tag = "core:treasure", weight = 4 } ]

95
mods/core/prefabs.toml Normal file
View File

@@ -0,0 +1,95 @@
# Handgezeichnete Raeume (Ticket 10). Marker stehen IM Block — der Sinn von
# ASCII ist, dass der Modder den Raum sieht.
#
# # Wand e Gegner-Platz > Treppe n NPC-Platz
# . Boden l Beute-Platz < Eingang
# + Tuer (Korridore duerfen nur hier andocken)
#
# Ein `e` sagt "hier steht ein Gegner", NICHT welcher — das entscheidet die
# Etage. Nur so laesst sich derselbe Raum auf Etage 1 und Etage 5 verwenden.
[[prefabs]]
id = "entrance_hall"
tags = ["core:entrance"]
layout = """
#####+#####
#.........#
#....<....#
#.........#
+.........+
#.........#
#####+#####
"""
[[prefabs]]
id = "stair_room"
tags = ["core:stairs"]
layout = """
#######
#.....#
#..>..#
+.....+
#..l..#
#######
"""
[[prefabs]]
id = "market"
tags = ["core:merchant"]
layout = """
####+####
#.......#
#...n...#
+.......+
#..l.l..#
####+####
"""
[[prefabs]]
id = "crossing"
tags = ["core:normal"]
layout = """
####+####
#.......#
#..e...e#
+.......+
#..l....#
#.......#
####+####
"""
[[prefabs]]
id = "pillars"
tags = ["core:normal"]
layout = """
#####+#####
#.........#
#.##...##.#
+.##.e.##.+
#.........#
#..l...l..#
#####+#####
"""
[[prefabs]]
id = "closet"
tags = ["core:normal"]
layout = """
#####
#...#
+.l.+
#.e.#
#####
"""
[[prefabs]]
id = "vault"
tags = ["core:treasure"]
layout = """
#######
#.....#
#.lll.#
+..e..#
#.....#
#######
"""

View File

@@ -14,7 +14,7 @@ use serde::Deserialize;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use toml::Spanned; use toml::Spanned;
use super::registries::{Category, CharacterOption, Item, Registries, Slot}; use super::registries::{Category, CharacterOption, FloorDef, Item, Prefab, Registries, Slot};
/// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus /// Genau drei harte Fehler (Ticket 04). Alles Uebrige ist Warnung plus
/// Verwerfen des **einzelnen Eintrags**. /// Verwerfen des **einzelnen Eintrags**.
@@ -244,6 +244,10 @@ struct ModFile {
categories: Vec<Spanned<toml::Value>>, categories: Vec<Spanned<toml::Value>>,
#[serde(default)] #[serde(default)]
options: Vec<Spanned<toml::Value>>, options: Vec<Spanned<toml::Value>>,
#[serde(default)]
prefabs: Vec<Spanned<toml::Value>>,
#[serde(default)]
floors: Vec<Spanned<toml::Value>>,
} }
/// Damit die vier Registry-Typen durch dieselbe Einlese-Schleife passen. /// Damit die vier Registry-Typen durch dieselbe Einlese-Schleife passen.
@@ -272,6 +276,8 @@ impl_entry!(Item);
impl_entry!(Slot); impl_entry!(Slot);
impl_entry!(Category); impl_entry!(Category);
impl_entry!(CharacterOption); impl_entry!(CharacterOption);
impl_entry!(Prefab);
impl_entry!(FloorDef);
struct FileCtx<'a> { struct FileCtx<'a> {
module: &'a str, module: &'a str,
@@ -366,6 +372,66 @@ fn resolve(reg: &mut Registries, warnings: &mut Vec<String>) {
false false
} }
}); });
// Ticket 10: `layout`-Zeilen muessen gleich lang sein, sonst ist der Block
// kein Rechteck und die Platzierung rechnet mit Muell. Ein Zweizeiler, der
// eine ganze Klasse von Modder-Frust abfaengt.
reg.prefabs.retain(|id, prefab| {
let rows = prefab.rows();
if rows.is_empty() {
warnings.push(format!("{id}: layout ist leer — verworfen"));
return false;
}
let width = rows[0].chars().count();
match rows.iter().position(|r| r.chars().count() != width) {
Some(i) => {
warnings.push(format!(
"{id}: layout-Zeile {} ist {} Zeichen breit, erwartet {width} — verworfen",
i + 1,
rows[i].chars().count()
));
false
}
None => true,
}
});
// Ticket 10: Passen die Pflicht-Tags grundsaetzlich nicht in `size`, ist das
// ein **Datenfehler und beim Laden erkennbar**. Zur Laufzeit gibt es dafuer
// dann keinen Wiederholungszaehler und keinen Rueckfallpfad — die Pruefung
// sitzt dort, wo der Fehler gemacht wurde.
let prefabs = &reg.prefabs;
reg.floors.retain(|id, floor| {
let mut needed = 0u32;
for tag in &floor.required {
let smallest = prefabs
.values()
.filter(|p| p.tags.iter().any(|t| t == tag))
.map(|p| {
let (w, h) = p.size();
(w as u32 + 1) * (h as u32 + 1)
})
.min();
match smallest {
None => {
warnings.push(format!("{id}: kein Prefab mit dem Pflicht-Tag '{tag}'"));
return false;
}
Some(area) => needed += area,
}
}
let available = floor.size.0 as u32 * floor.size.1 as u32;
if needed > available {
warnings.push(format!(
"{id}: Pflichträume brauchen mindestens {needed} Felder, size bietet {available}"
));
return false;
}
true
});
} }
// ============================================================================ // ============================================================================
@@ -414,6 +480,8 @@ pub fn load_mods() -> Result<(Registries, Vec<String>), LoadError> {
parse_entries(parsed.slots, &ctx, &mut reg.slots, &mut warnings); parse_entries(parsed.slots, &ctx, &mut reg.slots, &mut warnings);
parse_entries(parsed.categories, &ctx, &mut reg.categories, &mut warnings); parse_entries(parsed.categories, &ctx, &mut reg.categories, &mut warnings);
parse_entries(parsed.options, &ctx, &mut reg.options, &mut warnings); parse_entries(parsed.options, &ctx, &mut reg.options, &mut warnings);
parse_entries(parsed.prefabs, &ctx, &mut reg.prefabs, &mut warnings);
parse_entries(parsed.floors, &ctx, &mut reg.floors, &mut warnings);
} }
} }

View File

@@ -5,4 +5,4 @@ pub mod loader;
pub mod registries; pub mod registries;
pub use loader::{load_mods, write_log}; pub use loader::{load_mods, write_log};
pub use registries::Registries; pub use registries::{FloorDef, Prefab, Registries};

View File

@@ -92,6 +92,62 @@ pub struct CharacterOption {
// Startausruestung brauchen Kampf bzw. Inventar. // 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 /// Alle geladenen Definitionen. Liegt **neben** `Game`, nicht darin — als Feld
/// kollidierte `&mut game.player` mit `&game.registries` bei jedem /// kollidierte `&mut game.player` mit `&game.registries` bei jedem
/// Methodenaufruf (Ticket 07). /// Methodenaufruf (Ticket 07).
@@ -101,8 +157,10 @@ pub struct Registries {
pub slots: HashMap<String, Slot>, pub slots: HashMap<String, Slot>,
pub categories: HashMap<String, Category>, pub categories: HashMap<String, Category>,
pub options: HashMap<String, CharacterOption>, pub options: HashMap<String, CharacterOption>,
// TODO(M2+): `affixes`, `abilities`, `quests`, `creatures`, `prefabs`, pub prefabs: HashMap<String, Prefab>,
// `floors`, `loot_tables`, `scripts` — zwoelf sind es am Ende. pub floors: HashMap<String, FloorDef>,
// TODO(M3+): `affixes`, `abilities`, `quests`, `creatures`, `loot_tables`,
// `scripts` — zwoelf sind es am Ende.
} }
impl Registries { impl Registries {
@@ -113,4 +171,21 @@ impl Registries {
v.sort_by_key(|s| (s.order, s.id.clone())); v.sort_by_key(|s| (s.order, s.id.clone()));
v 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)
}
} }

View File

@@ -13,12 +13,13 @@ pub enum Direction {
/// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon. /// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon.
/// ///
// TODO(M2+): `UseAbility`, `UseItem`, `Equip`, `Unequip`, `Drop`, `PickUp`, // TODO(M3+): `UseAbility`, `UseItem`, `Equip`, `Unequip`, `Drop`, `PickUp`,
// `Descend`, `Talk`, `Buy`, `Sell` — und `AssignPoints` aus 15. // `Talk`, `Buy`, `Sell` — und `AssignPoints` aus 15.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Command { pub enum Command {
Move(Direction), Move(Direction),
Wait, Wait,
Descend,
} }
/// Ergebnis eines Kommandos — der Befund aus dem Durchstich in Ticket 12: /// Ergebnis eines Kommandos — der Befund aus dem Durchstich in Ticket 12:

View File

@@ -1,4 +1,10 @@
//! Etage — Ticket 10 entscheidet den Innenbau, M1 baut einen Testraum von Hand. //! Etage — Innenbau aus Ticket 10.
//!
//! `Room.enemy_spawns`, `loot` und `cleared` liest in M2 noch niemand: Sie
//! brauchen Kampf (M3) und Beutetabellen (M4). Der Generator fuellt sie aber
//! bereits, weil die Marker im Prefab stehen — sie erst spaeter zu lesen
//! hiesse, die Prefabs zweimal zu parsen.
#![allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tile { pub enum Tile {
@@ -9,16 +15,53 @@ pub enum Tile {
Entrance, Entrance,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Rect {
pub x: u16,
pub y: u16,
pub w: u16,
pub h: u16,
}
impl Rect {
/// Ueberlappt oder beruehrt — inklusive einem Feld Rand, damit zwei Raeume
/// nicht Wand an Wand kleben.
pub fn touches(&self, other: &Rect) -> bool {
self.x < other.x + other.w + 1
&& other.x < self.x + self.w + 1
&& self.y < other.y + other.h + 1
&& other.y < self.y + self.h + 1
}
pub fn contains(&self, x: u16, y: u16) -> bool {
x >= self.x && y >= self.y && x < self.x + self.w && y < self.y + self.h
}
}
#[derive(Debug)]
pub struct Room {
/// Fuer `room_entered` und die Trigger am Prefab (Ticket 08/10).
pub prefab: String,
pub rect: Rect,
/// Die `e`-Plaetze aus dem Prefab.
// TODO(M3): wird zu `enemies: Vec<Creature>` — was einzieht, entscheidet
// die `creatures`-Zeile der Etage (Ticket 10/14).
pub enemy_spawns: Vec<(u16, u16)>,
/// Die `l`-Plaetze. Ticket 16: erst beim **Aufheben** aufgeloest, deshalb
/// stehen hier Positionen und keine Gegenstaende.
pub loot: Vec<(u16, u16)>,
pub cleared: bool,
}
#[derive(Debug)] #[derive(Debug)]
pub struct Floor { pub struct Floor {
pub number: u8, pub number: u8,
pub width: u16, pub width: u16,
pub height: u16, pub height: u16,
pub tiles: Vec<Tile>, pub tiles: Vec<Tile>,
// TODO(M2): `rooms: Vec<Room>` und `player_room: Option<usize>` aus Ticket pub rooms: Vec<Room>,
// 10 — mit ihnen kommen `room_entered`, Kampfbeginn und die /// `None` ist ein echter Zustand: Korridore gehoeren zu keinem Raum.
// Raum-Beutetabellen. M1 hat genau einen Raum und braucht die pub player_room: Option<usize>,
// Buchfuehrung noch nicht.
} }
impl Floor { impl Floor {
@@ -29,40 +72,7 @@ impl Floor {
self.tiles[y as usize * self.width as usize + x as usize] self.tiles[y as usize * self.width as usize + x as usize]
} }
/// Handgebauter Testraum. Ticket 10 legt fest, dass Prefabs als ASCII-Bloecke /// Startfeld: das `<` aus dem Eingangs-Prefab, sonst das erste begehbare.
/// geschrieben werden — deshalb steht der Raum hier schon in derselben Form,
/// auch wenn der Generator erst in M2 kommt.
pub fn test_room() -> Self {
const LAYOUT: &[&str] = &[
"########################",
"#......................#",
"#..####....######......#",
"#..#..........#........#",
"#..#..........#...##...#",
"#.....####....#...##...#",
"#........#........>....#",
"#..<.....#.............#",
"########################",
];
let width = LAYOUT[0].len() as u16;
let height = LAYOUT.len() as u16;
let tiles = LAYOUT
.iter()
.flat_map(|row| row.chars())
.map(|c| match c {
'#' => Tile::Wall,
'>' => Tile::Stairs,
'<' => Tile::Entrance,
'+' => Tile::Door,
_ => Tile::Floor,
})
.collect();
Self { number: 1, width, height, tiles }
}
/// Startfeld: das `<` im Layout, sonst das erste begehbare Feld.
pub fn entrance(&self) -> (u16, u16) { pub fn entrance(&self) -> (u16, u16) {
for y in 0..self.height { for y in 0..self.height {
for x in 0..self.width { for x in 0..self.width {
@@ -71,21 +81,18 @@ impl Floor {
} }
} }
} }
for y in 0..self.height {
for x in 0..self.width {
if self.tile(x, y) != Tile::Wall {
return (x, y);
}
}
}
(1, 1) (1, 1)
} }
}
#[cfg(test)] /// In welchem Raum steht diese Position — `None` heisst Korridor.
mod tests { pub fn room_at(&self, x: u16, y: u16) -> Option<usize> {
use super::*; self.rooms.iter().position(|r| r.rect.contains(x, y))
#[test]
fn testraum_ist_rechteckig_und_hat_eingang() {
let f = Floor::test_room();
assert_eq!(f.tiles.len(), f.width as usize * f.height as usize);
assert_eq!(f.tile(f.entrance().0, f.entrance().1), Tile::Entrance);
// Rand ist geschlossen, sonst laeuft der Held aus der Karte.
assert_eq!(f.tile(0, 0), Tile::Wall);
assert_eq!(f.tile(f.width - 1, f.height - 1), Tile::Wall);
} }
} }

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
}
}

View File

@@ -4,14 +4,28 @@ pub mod attributes;
pub mod command; pub mod command;
pub mod creature; pub mod creature;
pub mod floor; pub mod floor;
pub mod generate;
pub mod rng;
pub use attributes::{Attribute, Modifier}; pub use attributes::{Attribute, Modifier};
pub use command::{Command, Direction, Outcome}; pub use command::{Command, Direction, Outcome};
pub use creature::{Creature, CreatureRef, ItemInstance}; pub use creature::{Creature, CreatureRef, ItemInstance};
pub use floor::{Floor, Tile}; pub use floor::{Floor, Tile};
pub use rng::Rng;
use crate::data::Registries; 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 struct Game {
pub player: Creature, pub player: Creature,
/// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf. /// Der **laufende Kampf**, nicht die Etage (Ticket 07/14). Leer = kein Kampf.
@@ -19,27 +33,36 @@ pub struct Game {
pub floor: Floor, pub floor: Floor,
pub player_pos: (u16, u16), pub player_pos: (u16, u16),
pub turn: u64, pub turn: u64,
/// Im Speicherstand, damit Nachladen nicht neu wuerfelt (Ticket 14).
pub rng: Rng,
pub over: Option<RunOutcome>,
/// Letzte Meldung fuer die Statuszeile. /// Letzte Meldung fuer die Statuszeile.
pub last: String, pub last: String,
// TODO(M3): `events: Vec<Event>` (Warteschlange aus Ticket 08) und der // TODO(M3): `events: Vec<Event>` — die Warteschlange aus Ticket 08.
// xorshift-Seed aus Ticket 14. Beide brauchen erst einen Trigger
// bzw. einen Wurf, um etwas zu tun.
// TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an // TODO(M4): `quests`, `current_exp`, `unspent_points` (Ticket 15 — an
// `Game`, nicht am `Creature`). // `Game`, nicht am `Creature`).
} }
impl Game { impl Game {
pub fn new(player: Creature) -> Self { /// Erzeugt die erste Etage. Schlaegt fehl, wenn keine `[[floors]]` mit
let floor = Floor::test_room(); /// `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(); let player_pos = floor.entrance();
Self { floor.player_room = floor.room_at(player_pos.0, player_pos.1);
Some(Self {
player, player,
enemies: Vec::new(), enemies: Vec::new(),
floor, floor,
player_pos, player_pos,
turn: 0, turn: 0,
rng,
over: None,
last: String::from("—"), last: String::from("—"),
} })
} }
pub fn creature(&self, who: CreatureRef) -> &Creature { pub fn creature(&self, who: CreatureRef) -> &Creature {
@@ -86,13 +109,14 @@ impl Game {
} }
/// Der **eine** Eingang, an dem ein Zug endet (Ticket 11). /// 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 { let outcome = match cmd {
Command::Move(dir) => self.step(dir), Command::Move(dir) => self.step(dir),
Command::Wait => { Command::Wait => {
self.last = String::from("gewartet"); self.last = String::from("gewartet");
Outcome::TurnPassed Outcome::TurnPassed
} }
Command::Descend => self.descend(reg),
}; };
// Nur ausgefuehrte Kommandos kosten einen Zug (Ticket 12). // Nur ausgefuehrte Kommandos kosten einen Zug (Ticket 12).
@@ -119,12 +143,51 @@ impl Game {
} }
self.player_pos = (nx, ny); 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) { self.last = match self.floor.tile(nx, ny) {
Tile::Stairs => String::from("Eine Treppe führt nach unten"), Tile::Stairs => String::from("Eine Treppe führt nach unten — '>' zum Absteigen"),
_ => format!("{},{}", nx, ny), _ => self.room_label(),
}; };
Outcome::TurnPassed 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)] #[cfg(test)]
@@ -138,7 +201,38 @@ mod tests {
hero.options = vec![String::from("core:dwarf"), String::from("core:warrior")]; hero.options = vec![String::from("core:dwarf"), String::from("core:warrior")];
hero.equipment hero.equipment
.insert(String::from("core:hand"), ItemInstance::new("core:iron_sword")); .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 /// Der Zielsatz von Meilenstein 1 als Behauptung: ein Wert, der aus einem
@@ -177,6 +271,7 @@ mod tests {
#[test] #[test]
fn wand_kostet_keinen_zug() { fn wand_kostet_keinen_zug() {
let (mut game, reg) = zwergenkrieger(); let (mut game, reg) = zwergenkrieger();
// Die Ecke oben links ist auf jeder erzeugten Etage Fels.
game.player_pos = (1, 1); game.player_pos = (1, 1);
assert!(matches!( assert!(matches!(

105
src/game/rng.rs Normal file
View File

@@ -0,0 +1,105 @@
//! Zufall — Ticket 14.
//!
//! Eigener xorshift64* statt der `rand`-Crate: acht Zeilen gegen eine
//! Dependency ist die eine Haelfte. Die andere ist, dass ein selbst gehaltener
//! Seed die Save-Scumming-Frage ueberhaupt erst beantwortbar macht — und die
//! Antwort ist **nein, es wird nicht neu gewuerfelt**.
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rng(u64);
impl Rng {
pub fn new(seed: u64) -> Self {
// xorshift bleibt bei 0 fuer immer bei 0.
Self(if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed })
}
/// Entropie ohne Dependency. Nur fuer den *ersten* Seed eines Laufs — danach
/// steht er im Speicherstand und wird nie wieder frisch gezogen.
pub fn from_clock() -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0x5EED);
Self::new(nanos)
}
pub fn next_u64(&mut self) -> u64 {
let mut x = self.0;
x ^= x >> 12;
x ^= x << 25;
x ^= x >> 27;
self.0 = x;
x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}
/// Gleichverteilt in `0..n`. `n == 0` gibt 0 — ein leerer Bereich ist kein
/// Absturzgrund, sondern schlicht kein Zug.
pub fn below(&mut self, n: usize) -> usize {
if n == 0 { 0 } else { (self.next_u64() % n as u64) as usize }
}
pub fn chance(&mut self, p: f32) -> bool {
(self.next_u64() % 10_000) < (p.clamp(0.0, 1.0) * 10_000.0) as u64
}
/// Zieht einen Index gewichtet. Gewichte statt Prozente — sie komponieren,
/// wenn ein Mod einen Eintrag ergaenzt (Ticket 16).
pub fn weighted(&mut self, weights: &[u32]) -> Option<usize> {
let total: u32 = weights.iter().sum();
if total == 0 {
return None;
}
let mut roll = (self.next_u64() % total as u64) as u32;
for (i, w) in weights.iter().enumerate() {
if roll < *w {
return Some(i);
}
roll -= w;
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn derselbe_seed_liefert_dieselbe_folge() {
let mut a = Rng::new(42);
let mut b = Rng::new(42);
let fa: Vec<u64> = (0..8).map(|_| a.next_u64()).collect();
let fb: Vec<u64> = (0..8).map(|_| b.next_u64()).collect();
assert_eq!(fa, fb, "Nachladen wuerde sonst neu wuerfeln (Ticket 14)");
}
#[test]
fn seed_null_bleibt_nicht_stehen() {
let mut r = Rng::new(0);
assert_ne!(r.next_u64(), 0);
assert_ne!(r.next_u64(), 0);
}
#[test]
fn below_bleibt_im_bereich() {
let mut r = Rng::new(7);
for _ in 0..500 {
assert!(r.below(5) < 5);
}
assert_eq!(r.below(0), 0);
}
#[test]
fn gewichtung_trifft_nur_eintraege_mit_gewicht() {
let mut r = Rng::new(1);
// Der mittlere Eintrag hat Gewicht 0 und darf nie kommen.
for _ in 0..500 {
let i = r.weighted(&[3, 0, 1]).expect("Gesamtgewicht > 0");
assert_ne!(i, 1);
}
assert_eq!(r.weighted(&[0, 0]), None, "nur Nullgewichte = kein Zug");
}
}

View File

@@ -27,7 +27,15 @@ fn main() {
}; };
let log_name = data::write_log(&warnings); let log_name = data::write_log(&warnings);
let game = game::Game::new(starting_hero());
// Der Seed wird genau einmal frisch gezogen. Ab hier steht er im Spiel und
// spaeter im Speicherstand — Nachladen wuerfelt nicht neu (Ticket 14).
let Some(game) = game::Game::new(starting_hero(), &registries, game::Rng::from_clock()) else {
eprintln!(
"Keine Etage mit number = 1 gefunden. Erwartet wird ein [[floors]]-Eintrag in mods/core."
);
std::process::exit(1);
};
if let Err(e) = ui::run(game, registries, warnings.len(), log_name) { if let Err(e) = ui::run(game, registries, warnings.len(), log_name) {
eprintln!("Terminal-Fehler: {e}"); eprintln!("Terminal-Fehler: {e}");

View File

@@ -11,7 +11,7 @@ use super::grid::GridWidget;
use super::screen::{Response, Screen}; use super::screen::{Response, Screen};
use super::theme; use super::theme;
use crate::data::Registries; use crate::data::Registries;
use crate::game::{Attribute, CreatureRef, Game, Outcome}; use crate::game::{Attribute, CreatureRef, Game, Outcome, RunOutcome};
pub struct App { pub struct App {
/// `game` und `registries` sind **getrennte Felder**, damit die disjunkten /// `game` und `registries` sind **getrennte Felder**, damit die disjunkten
@@ -66,6 +66,11 @@ pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option
while app.screens.last().is_some_and(Screen::is_overlay) { while app.screens.last().is_some_and(Screen::is_overlay) {
app.screens.pop(); app.screens.pop();
} }
// Ticket 10/14: Der Lauf endet ueber `Game`, nicht ueber
// die UI — die reagiert nur darauf.
if app.game.over.is_some() {
app.screens.push(Screen::GameOver);
}
} }
Response::Quit => break Ok(()), Response::Quit => break Ok(()),
} }
@@ -82,6 +87,7 @@ fn draw(frame: &mut Frame, app: &App) {
Screen::MainMenu => draw_main_menu(frame, area, app), Screen::MainMenu => draw_main_menu(frame, area, app),
Screen::Explore => draw_explore(frame, area, app), Screen::Explore => draw_explore(frame, area, app),
Screen::CharacterSheet => draw_sheet(frame, area, app), Screen::CharacterSheet => draw_sheet(frame, area, app),
Screen::GameOver => draw_game_over(frame, area, app),
} }
} }
} }
@@ -128,7 +134,7 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
}; };
frame.render_widget( frame.render_widget(
Paragraph::new(format!( Paragraph::new(format!(
"Zug {} │ {} │ c Charakterbogen │ {}", "Zug {} │ {} │ hjkl/Pfeile .warten >absteigen c Bogen │ {}",
app.game.turn, app.game.player.name, note app.game.turn, app.game.player.name, note
)) ))
.right_aligned() .right_aligned()
@@ -137,6 +143,38 @@ fn draw_explore(frame: &mut Frame, area: Rect, app: &App) {
); );
} }
/// Der Lauf wird am Ende **erzaehlt** — das ist der Moment, fuer den man die
/// Etagen gespielt hat, und der Grund, warum Ticket 14 hier keinen Einzeiler im
/// Hauptmenue wollte.
fn draw_game_over(frame: &mut Frame, area: Rect, app: &App) {
let (titel, farbe, zeile) = match app.game.over {
Some(RunOutcome::Won) => (
" Gewonnen ",
Color::Green,
"Du steigst ans Licht — der Keep liegt hinter dir.",
),
_ => (" Gestorben ", Color::Red, "Der Keep behält dich."),
};
let text = vec![
Line::from(app.game.player.name.clone().bold()),
Line::from(""),
Line::from(zeile),
Line::from(""),
Line::from(format!("Etage {}", app.game.floor.number)),
Line::from(format!("Züge {}", app.game.turn)),
Line::from(""),
Line::from("Taste drücken").style(Style::new().fg(Color::DarkGray)),
];
frame.render_widget(
Paragraph::new(text)
.centered()
.block(Block::bordered().title(titel).style(Style::new().fg(farbe))),
area,
);
}
/// Der Charakterbogen ist eine **Schleife ueber Registries**, kein gemauertes /// Der Charakterbogen ist eine **Schleife ueber Registries**, kein gemauertes
/// Layout — Modder koennen Slots und Wahlkategorien ergaenzen (Ticket 02). /// Layout — Modder koennen Slots und Wahlkategorien ergaenzen (Ticket 02).
fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) { fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {

View File

@@ -16,12 +16,14 @@ pub struct GridWidget<'a> {
impl Widget for GridWidget<'_> { impl Widget for GridWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) { fn render(self, area: Rect, buf: &mut Buffer) {
for y in 0..self.floor.height { // Ausschnitt, der dem Helden folgt. Etage 5 ist breiter als ein
for x in 0..self.floor.width { // 80-Spalten-Terminal — ohne das laeuft er aus dem Bild.
let (px, py) = (area.x + x, area.y + y); let (ox, oy) = offset(self.floor.width, self.floor.height, self.player, area);
if px >= area.right() || py >= area.bottom() {
continue; for vy in 0..area.height.min(self.floor.height) {
} for vx in 0..area.width.min(self.floor.width) {
let (x, y) = (ox + vx, oy + vy);
let (px, py) = (area.x + vx, area.y + vy);
let (symbol, color) = match self.floor.tile(x, y) { let (symbol, color) = match self.floor.tile(x, y) {
Tile::Wall => ("█", Color::DarkGray), Tile::Wall => ("█", Color::DarkGray),
@@ -34,7 +36,7 @@ impl Widget for GridWidget<'_> {
} }
} }
let (sx, sy) = (area.x + self.player.0, area.y + self.player.1); let (sx, sy) = (area.x + self.player.0 - ox, area.y + self.player.1 - oy);
if sx < area.right() && sy < area.bottom() { if sx < area.right() && sy < area.bottom() {
buf[(sx, sy)] buf[(sx, sy)]
.set_symbol("@") .set_symbol("@")
@@ -42,3 +44,36 @@ impl Widget for GridWidget<'_> {
} }
} }
} }
/// Linke obere Ecke des Ausschnitts: mittig um den Helden, an den Kartenrand
/// geklemmt. Passt die Karte ganz hinein, ist der Ausschnitt (0, 0).
fn offset(map_w: u16, map_h: u16, player: (u16, u16), area: Rect) -> (u16, u16) {
let clamp = |pos: u16, view: u16, map: u16| {
if map <= view {
return 0;
}
pos.saturating_sub(view / 2).min(map - view)
};
(clamp(player.0, area.width, map_w), clamp(player.1, area.height, map_h))
}
#[cfg(test)]
mod tests {
use super::*;
fn view(w: u16, h: u16) -> Rect {
Rect { x: 0, y: 0, width: w, height: h }
}
#[test]
fn ausschnitt_bleibt_auf_der_karte() {
// Karte passt hinein: kein Verschieben.
assert_eq!(offset(20, 10, (5, 5), view(40, 20)), (0, 0));
// Held am linken Rand: nicht ins Negative.
assert_eq!(offset(80, 40, (2, 2), view(40, 20)), (0, 0));
// Held mittig: zentriert.
assert_eq!(offset(80, 40, (40, 20), view(40, 20)), (20, 10));
// Held am rechten Rand: an der Karte geklemmt, nicht darueber hinaus.
assert_eq!(offset(80, 40, (79, 39), view(40, 20)), (40, 20));
}
}

View File

@@ -11,9 +11,12 @@ pub enum Screen {
MainMenu, MainMenu,
Explore, Explore,
CharacterSheet, CharacterSheet,
// TODO(M2+): `CharacterCreation`, `Combat`, `Inventory`, `QuestLog`, /// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer
// `Shop`, `Dialog`, `Confirm`, `GameOver` — elf sind es am Ende /// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
// (Ticket 11/14). /// Lauf am Ende *erzaehlt* wird (Ticket 14).
GameOver,
// TODO(M3+): `CharacterCreation`, `Combat`, `Inventory`, `QuestLog`,
// `Shop`, `Dialog`, `Confirm` — elf sind es am Ende (11/14).
} }
/// Der Screen **gibt zurueck, statt selbst zu schieben**: sonst haelt er ein /// Der Screen **gibt zurueck, statt selbst zu schieben**: sonst haelt er ein
@@ -58,6 +61,7 @@ impl Screen {
Response::Command(Command::Move(Direction::East)) Response::Command(Command::Move(Direction::East))
} }
KeyCode::Char('.') => Response::Command(Command::Wait), KeyCode::Char('.') => Response::Command(Command::Wait),
KeyCode::Char('>') => Response::Command(Command::Descend),
// Screen-Navigation ist reine UI und fasst `Game` nie an. // Screen-Navigation ist reine UI und fasst `Game` nie an.
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet), KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
KeyCode::Esc => Response::Pop, KeyCode::Esc => Response::Pop,
@@ -68,6 +72,8 @@ impl Screen {
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop, KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop,
_ => Response::None, _ => Response::None,
}, },
Screen::GameOver => Response::Quit,
} }
} }
} }