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:
53
mods/core/floors.toml
Normal file
53
mods/core/floors.toml
Normal 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
95
mods/core/prefabs.toml
Normal 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..#
|
||||
#.....#
|
||||
#######
|
||||
"""
|
||||
@@ -14,7 +14,7 @@ use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
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
|
||||
/// Verwerfen des **einzelnen Eintrags**.
|
||||
@@ -244,6 +244,10 @@ struct ModFile {
|
||||
categories: Vec<Spanned<toml::Value>>,
|
||||
#[serde(default)]
|
||||
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.
|
||||
@@ -272,6 +276,8 @@ impl_entry!(Item);
|
||||
impl_entry!(Slot);
|
||||
impl_entry!(Category);
|
||||
impl_entry!(CharacterOption);
|
||||
impl_entry!(Prefab);
|
||||
impl_entry!(FloorDef);
|
||||
|
||||
struct FileCtx<'a> {
|
||||
module: &'a str,
|
||||
@@ -366,6 +372,66 @@ fn resolve(reg: &mut Registries, warnings: &mut Vec<String>) {
|
||||
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 = ®.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.categories, &ctx, &mut reg.categories, &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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,4 +5,4 @@ pub mod loader;
|
||||
pub mod registries;
|
||||
|
||||
pub use loader::{load_mods, write_log};
|
||||
pub use registries::Registries;
|
||||
pub use registries::{FloorDef, Prefab, Registries};
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,12 +13,13 @@ pub enum Direction {
|
||||
|
||||
/// Ticket 11 hat zwoelf Commands festgelegt; M1 braucht zwei davon.
|
||||
///
|
||||
// TODO(M2+): `UseAbility`, `UseItem`, `Equip`, `Unequip`, `Drop`, `PickUp`,
|
||||
// `Descend`, `Talk`, `Buy`, `Sell` — und `AssignPoints` aus 15.
|
||||
// TODO(M3+): `UseAbility`, `UseItem`, `Equip`, `Unequip`, `Drop`, `PickUp`,
|
||||
// `Talk`, `Buy`, `Sell` — und `AssignPoints` aus 15.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Command {
|
||||
Move(Direction),
|
||||
Wait,
|
||||
Descend,
|
||||
}
|
||||
|
||||
/// Ergebnis eines Kommandos — der Befund aus dem Durchstich in Ticket 12:
|
||||
|
||||
@@ -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)]
|
||||
pub enum Tile {
|
||||
@@ -9,16 +15,53 @@ pub enum Tile {
|
||||
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)]
|
||||
pub struct Floor {
|
||||
pub number: u8,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
pub tiles: Vec<Tile>,
|
||||
// TODO(M2): `rooms: Vec<Room>` und `player_room: Option<usize>` aus Ticket
|
||||
// 10 — mit ihnen kommen `room_entered`, Kampfbeginn und die
|
||||
// Raum-Beutetabellen. M1 hat genau einen Raum und braucht die
|
||||
// Buchfuehrung noch nicht.
|
||||
pub rooms: Vec<Room>,
|
||||
/// `None` ist ein echter Zustand: Korridore gehoeren zu keinem Raum.
|
||||
pub player_room: Option<usize>,
|
||||
}
|
||||
|
||||
impl Floor {
|
||||
@@ -29,40 +72,7 @@ impl Floor {
|
||||
self.tiles[y as usize * self.width as usize + x as usize]
|
||||
}
|
||||
|
||||
/// Handgebauter Testraum. Ticket 10 legt fest, dass Prefabs als ASCII-Bloecke
|
||||
/// 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.
|
||||
/// Startfeld: das `<` aus dem Eingangs-Prefab, sonst das erste begehbare.
|
||||
pub fn entrance(&self) -> (u16, u16) {
|
||||
for y in 0..self.height {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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);
|
||||
/// In welchem Raum steht diese Position — `None` heisst Korridor.
|
||||
pub fn room_at(&self, x: u16, y: u16) -> Option<usize> {
|
||||
self.rooms.iter().position(|r| r.rect.contains(x, y))
|
||||
}
|
||||
}
|
||||
|
||||
453
src/game/generate.rs
Normal file
453
src/game/generate.rs
Normal 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, ®, &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
|
||||
}
|
||||
}
|
||||
117
src/game/mod.rs
117
src/game/mod.rs
@@ -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, ®, 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, ®), 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, ®), 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!(
|
||||
|
||||
105
src/game/rng.rs
Normal file
105
src/game/rng.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
10
src/main.rs
10
src/main.rs
@@ -27,7 +27,15 @@ fn main() {
|
||||
};
|
||||
|
||||
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(), ®istries, 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) {
|
||||
eprintln!("Terminal-Fehler: {e}");
|
||||
|
||||
@@ -11,7 +11,7 @@ use super::grid::GridWidget;
|
||||
use super::screen::{Response, Screen};
|
||||
use super::theme;
|
||||
use crate::data::Registries;
|
||||
use crate::game::{Attribute, CreatureRef, Game, Outcome};
|
||||
use crate::game::{Attribute, CreatureRef, Game, Outcome, RunOutcome};
|
||||
|
||||
pub struct App {
|
||||
/// `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) {
|
||||
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(()),
|
||||
}
|
||||
@@ -82,6 +87,7 @@ fn draw(frame: &mut Frame, app: &App) {
|
||||
Screen::MainMenu => draw_main_menu(frame, area, app),
|
||||
Screen::Explore => draw_explore(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(
|
||||
Paragraph::new(format!(
|
||||
"Zug {} │ {} │ c Charakterbogen │ {}",
|
||||
"Zug {} │ {} │ hjkl/Pfeile .warten >absteigen c Bogen │ {}",
|
||||
app.game.turn, app.game.player.name, note
|
||||
))
|
||||
.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
|
||||
/// Layout — Modder koennen Slots und Wahlkategorien ergaenzen (Ticket 02).
|
||||
fn draw_sheet(frame: &mut Frame, area: Rect, app: &App) {
|
||||
|
||||
@@ -16,12 +16,14 @@ pub struct GridWidget<'a> {
|
||||
|
||||
impl Widget for GridWidget<'_> {
|
||||
fn render(self, area: Rect, buf: &mut Buffer) {
|
||||
for y in 0..self.floor.height {
|
||||
for x in 0..self.floor.width {
|
||||
let (px, py) = (area.x + x, area.y + y);
|
||||
if px >= area.right() || py >= area.bottom() {
|
||||
continue;
|
||||
}
|
||||
// Ausschnitt, der dem Helden folgt. Etage 5 ist breiter als ein
|
||||
// 80-Spalten-Terminal — ohne das laeuft er aus dem Bild.
|
||||
let (ox, oy) = offset(self.floor.width, self.floor.height, self.player, area);
|
||||
|
||||
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) {
|
||||
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() {
|
||||
buf[(sx, sy)]
|
||||
.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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,12 @@ pub enum Screen {
|
||||
MainMenu,
|
||||
Explore,
|
||||
CharacterSheet,
|
||||
// TODO(M2+): `CharacterCreation`, `Combat`, `Inventory`, `QuestLog`,
|
||||
// `Shop`, `Dialog`, `Confirm`, `GameOver` — elf sind es am Ende
|
||||
// (Ticket 11/14).
|
||||
/// Vollbild, kein Overlay. Bewusst nicht die lazy Variante mit einer
|
||||
/// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der
|
||||
/// 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
|
||||
@@ -58,6 +61,7 @@ impl Screen {
|
||||
Response::Command(Command::Move(Direction::East))
|
||||
}
|
||||
KeyCode::Char('.') => Response::Command(Command::Wait),
|
||||
KeyCode::Char('>') => Response::Command(Command::Descend),
|
||||
// Screen-Navigation ist reine UI und fasst `Game` nie an.
|
||||
KeyCode::Char('c') => Response::Push(Screen::CharacterSheet),
|
||||
KeyCode::Esc => Response::Pop,
|
||||
@@ -68,6 +72,8 @@ impl Screen {
|
||||
KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('q') => Response::Pop,
|
||||
_ => Response::None,
|
||||
},
|
||||
|
||||
Screen::GameOver => Response::Quit,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user