diff --git a/mods/core/floors.toml b/mods/core/floors.toml new file mode 100644 index 0000000..026b9d0 --- /dev/null +++ b/mods/core/floors.toml @@ -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 } ] diff --git a/mods/core/prefabs.toml b/mods/core/prefabs.toml new file mode 100644 index 0000000..8fd5be4 --- /dev/null +++ b/mods/core/prefabs.toml @@ -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..# +#.....# +####### +""" diff --git a/src/data/loader.rs b/src/data/loader.rs index b4c999b..ec7f361 100644 --- a/src/data/loader.rs +++ b/src/data/loader.rs @@ -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>, #[serde(default)] options: Vec>, + #[serde(default)] + prefabs: Vec>, + #[serde(default)] + floors: Vec>, } /// 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) { 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), 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); } } diff --git a/src/data/mod.rs b/src/data/mod.rs index 3e069db..4fc38d6 100644 --- a/src/data/mod.rs +++ b/src/data/mod.rs @@ -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}; diff --git a/src/data/registries.rs b/src/data/registries.rs index c768aaf..aae49e1 100644 --- a/src/data/registries.rs +++ b/src/data/registries.rs @@ -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, + pub layout: String, + /// Ersetzt die Etagentabelle fuer die `l`-Plaetze dieses Raums (Ticket 16). + #[serde(default)] + pub loot: Option, + #[serde(default)] + pub overrides: Option, +} + +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, + #[serde(default)] + pub prefabs: Vec, + #[serde(default)] + pub overrides: Option, + // 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, pub categories: HashMap, pub options: HashMap, - // TODO(M2+): `affixes`, `abilities`, `quests`, `creatures`, `prefabs`, - // `floors`, `loot_tables`, `scripts` — zwoelf sind es am Ende. + pub prefabs: HashMap, + pub floors: HashMap, + // 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) + } } diff --git a/src/game/command.rs b/src/game/command.rs index 6fd41d0..e82b1f1 100644 --- a/src/game/command.rs +++ b/src/game/command.rs @@ -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: diff --git a/src/game/floor.rs b/src/game/floor.rs index e4de2d2..0d974d9 100644 --- a/src/game/floor.rs +++ b/src/game/floor.rs @@ -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` — 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, - // TODO(M2): `rooms: Vec` und `player_room: Option` 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, + /// `None` ist ein echter Zustand: Korridore gehoeren zu keinem Raum. + pub player_room: Option, } 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 { + self.rooms.iter().position(|r| r.rect.contains(x, y)) } } diff --git a/src/game/generate.rs b/src/game/generate.rs new file mode 100644 index 0000000..e7bc24c --- /dev/null +++ b/src/game/generate.rs @@ -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, +} + +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 = 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 = 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) { + 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> { + 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> = 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 + } +} diff --git a/src/game/mod.rs b/src/game/mod.rs index 7564c57..30d7278 100644 --- a/src/game/mod.rs +++ b/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, /// Letzte Meldung fuer die Statuszeile. pub last: String, - // TODO(M3): `events: Vec` (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` — 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 { + 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!( diff --git a/src/game/rng.rs b/src/game/rng.rs new file mode 100644 index 0000000..272699b --- /dev/null +++ b/src/game/rng.rs @@ -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 { + 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 = (0..8).map(|_| a.next_u64()).collect(); + let fb: Vec = (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"); + } +} diff --git a/src/main.rs b/src/main.rs index 9422feb..94b8e42 100644 --- a/src/main.rs +++ b/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}"); diff --git a/src/ui/app.rs b/src/ui/app.rs index d02a9ac..e37065a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -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) { diff --git a/src/ui/grid.rs b/src/ui/grid.rs index 9a03fd1..2c8999c 100644 --- a/src/ui/grid.rs +++ b/src/ui/grid.rs @@ -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)); + } +} diff --git a/src/ui/screen.rs b/src/ui/screen.rs index 4b51b8b..2aff10a 100644 --- a/src/ui/screen.rs +++ b/src/ui/screen.rs @@ -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, } } }