diff --git a/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md b/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md index afb873d..c79955b 100644 --- a/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md +++ b/.scratch/rustkeep-1.0/issues/11-ui-geruest-screen-modell.md @@ -252,3 +252,23 @@ Alternative gewesen — weniger Tastendrücke, aber eine Bedienung, die es sonst Dazu zeigt die Gegnerliste unter jedem Lebensbalken die **Ansage** aus Ticket 14 (`↳ Wuchtschlag → dich`, sonst `↳ zögert`). Sie steht bewusst als eigene Zeile und nicht als Kürzel hinter dem Namen: Sie ist die Information, auf der die Entscheidung dieses Zuges beruht. + +### Nachtrag — die Werkbank (Debug-Screen, nach M4) + +Ein zwölfter Screen, `F12` aus Erkundung und Kampf: Gegenstandsliste mit Cursor, dazu vier Tasten für +heilen, absteigen, Raum leeren. + +**Sie geht durch dieselbe Tür wie alles andere.** Es wäre kürzer gewesen, den Screen direkt auf +`Game` schreiben zu lassen — er hat den `&mut` ohnehin nicht, aber man hätte ihn ihm geben können. Das +hätte die Zusage aus Ticket 11 gebrochen, dass `Response::Command` der einzige Weg nach `game/` ist, +und zwar ausgerechnet an der Stelle, die am seltensten gelesen wird. Stattdessen ein Kommando +`Debug(Cheat)` mit vier Varianten — **eins**, nicht vier, sonst zögen sich Cheats durch `rejects` und +`perform` wie echte Kommandos. In `rejects` steht genau eine Zeile: Ein Cheat wird nie abgewiesen. + +`ClearRoom` setzt die Lebenspunkte auf null, statt `enemies` zu leeren. So läuft der normale Weg samt +Todes-Triggern, Erfahrung (später) und „Der Raum ist geräumt" — ein Cheat, der am Spielablauf +vorbeigeht, testet den Spielablauf nicht. + +**Immer mitgebaut**, nicht hinter `cfg(debug_assertions)`. Das wäre ein `#[cfg]` an der Variante *und* +an jedem `match` darüber — für ein Spiel, das niemand ausliefert. Ein `#[cfg]` reicht später nach, +wenn es je einen Release-Build gibt. diff --git a/src/data/registries.rs b/src/data/registries.rs index dcb8ded..1381a98 100644 --- a/src/data/registries.rs +++ b/src/data/registries.rs @@ -459,6 +459,15 @@ pub struct Registries { } impl Registries { + /// Alle Gegenstaende in stabiler Reihenfolge — fuer die Werkbank. Ohne das + /// haengt die Liste an der HashMap-Iterationsreihenfolge und der Cursor + /// zeigt bei jedem Start woanders hin. + pub fn items_ordered(&self) -> Vec<&Item> { + let mut v: Vec<&Item> = self.items.values().collect(); + v.sort_by(|a, b| a.id.cmp(&b.id)); + v + } + /// Slots in Anzeigereihenfolge. Der Charakterbogen schleift darueber, /// statt ein Layout zu mauern (Ticket 02). pub fn slots_ordered(&self) -> Vec<&Slot> { diff --git a/src/game/command.rs b/src/game/command.rs index 2d4d4d9..e133d1a 100644 --- a/src/game/command.rs +++ b/src/game/command.rs @@ -44,6 +44,26 @@ pub enum Command { ability: String, target: super::creature::CreatureRef, }, + /// Werkbank. **Ein** Kommando mit einer Variantenliste statt fuenf + /// Kommandos, die sich durch `rejects` und `perform` ziehen — ein Cheat + /// wird nie abgewiesen, das ist sein ganzer Zweck. + Debug(Cheat), +} + +/// Was die Werkbank kann. Bewusst klein: Was sich mit zwei vorhandenen +/// Kommandos nachstellen laesst, gehoert nicht hierher. +#[derive(Debug, Clone)] +pub enum Cheat { + /// Gibt den Gegenstand — zehn Stueck, wenn er stapelbar ist. Sonst muesste + /// man fuer hundert Gold hundertmal druecken. + Give(String), + /// Leben und Ausdauer voll. + Heal, + /// Eine Etage tiefer, ohne Treppe. + Descend, + /// Setzt alle Gegner auf null Leben. **Nicht** `enemies.clear()`: So laeuft + /// der normale Weg samt Todes-Triggern und „Der Raum ist geräumt". + ClearRoom, } /// Ergebnis eines Kommandos — der Befund aus dem Durchstich in Ticket 12: diff --git a/src/game/mod.rs b/src/game/mod.rs index 7a486e4..d52c9f0 100644 --- a/src/game/mod.rs +++ b/src/game/mod.rs @@ -14,7 +14,7 @@ pub mod rng; pub use action::{Action, ActiveModifier, DamageType, StateField, Target}; pub use attributes::{Attribute, Modifier}; -pub use command::{Command, Direction, Outcome}; +pub use command::{Cheat, Command, Direction, Outcome}; pub use condition::{Check, Condition, Who}; pub use creature::{Creature, CreatureRef, ItemInstance}; pub use event::{Event, EventKind, Trigger}; @@ -599,6 +599,8 @@ impl Game { // Sein eigenes Gold zurueckzuverkaufen ergibt keinen Sinn. Command::Sell(i) => (self.player.inventory[*i].def == "core:gold") .then_some("Gold verkauft man nicht"), + // Ein Cheat wird nie abgewiesen — das ist sein Zweck. + Command::Debug(_) => None, Command::Wait => None, } } @@ -746,6 +748,7 @@ impl Game { Command::Choose(i) => self.choose(*i, reg), Command::Buy(i) => self.buy(*i, reg), Command::Sell(i) => self.sell(*i, reg), + Command::Debug(cheat) => self.cheat(cheat, reg), Command::Drop(i) => { let inst = self.player.inventory.remove(*i); let name = self.item_label(&inst, reg); @@ -837,6 +840,37 @@ impl Game { self.log(format!("{name} verkauft fuer {preis} Gold")); } + /// Die Werkbank. Steht im Spielkern und nicht in der UI, weil sie durch + /// dieselbe Tuer muss wie alles andere: `Command` ist der einzige Weg + /// hinein (Ticket 11). + fn cheat(&mut self, cheat: &Cheat, reg: &Registries) { + match cheat { + Cheat::Give(id) => { + let stapelbar = reg.items.get(id).is_some_and(|d| d.stackable); + let inst = ItemInstance::stack(id.clone(), if stapelbar { 10 } else { 1 }); + let name = self.item_label(&inst, reg); + self.give(inst, reg); + self.log(format!("[debug] {name}")); + } + Cheat::Heal => { + let p = CreatureRef::Player; + self.player.health = self.attribute(p, Attribute::MaxHealth, None, reg); + self.player.stamina = self.attribute(p, Attribute::MaxStamina, None, reg); + self.log(String::from("[debug] geheilt")); + } + Cheat::Descend => { + self.log(String::from("[debug] abgestiegen")); + self.descend(reg); + } + Cheat::ClearRoom => { + for e in &mut self.enemies { + e.health = 0.0; + } + self.log(String::from("[debug] Raum geleert")); + } + } + } + /// Nimmt **ein Stueck** aus dem Eintrag an `index` und gibt es zurueck. /// Ueber den Index, nicht ueber die `def`: Zwei Traenke mit verschiedenen /// Affixen teilen sich die `def`, und `take` naehme den erstbesten. @@ -1688,4 +1722,31 @@ mod tests { "der leere Eintrag muss verschwinden" ); } + + /// Die Werkbank laeuft durch dieselbe Tuer wie alles andere — sie ist ein + /// `Command`, kein Seiteneingang in `Game`. Der Test haelt genau das fest. + #[test] + fn werkbank_gibt_stapelbares_im_stapel() { + let (mut game, reg) = zwergenkrieger(); + + let cheat = Command::Debug; + game.execute(cheat(Cheat::Give(String::from("core:gold"))), ®); + game.execute(cheat(Cheat::Give(String::from("core:iron_sword"))), ®); + assert_eq!(game.gold(), 10, "stapelbares kommt zu zehnt"); + assert_eq!(game.count_of("core:iron_sword"), 1, "der Rest einzeln"); + + game.player.health = 1.0; + game.player.stamina = 0.0; + game.execute(cheat(Cheat::Heal), ®); + let voll = game.attribute(CreatureRef::Player, Attribute::MaxHealth, None, ®); + assert_eq!(game.player.health, voll); + + // Gegner werden getoetet, nicht entfernt — so laeuft der normale Weg + // samt „Der Raum ist geräumt". + let ratte = reg.creatures.get("core:rat").expect("Riesenratte"); + game.enemies = vec![Creature::from_def(ratte)]; + game.execute(cheat(Cheat::ClearRoom), ®); + assert!(game.enemies.is_empty()); + assert!(game.journal.iter().any(|z| z.contains("geräumt")), "{:?}", game.journal); + } } diff --git a/src/ui/app.rs b/src/ui/app.rs index 97ca86e..d0efb10 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -139,6 +139,7 @@ fn draw(frame: &mut Frame, app: &App) { Screen::CharacterSheet => draw_sheet(frame, area, app), Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick), Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling), + Screen::Debug { pick } => draw_debug(frame, area, app, *pick), Screen::GameOver => draw_game_over(frame, area, app), } } @@ -344,6 +345,34 @@ fn draw_shop(frame: &mut Frame, area: Rect, app: &App, pick: usize, selling: boo ); } +/// Die Werkbank. Eine Liste, vier Tasten — was sich mit zwei vorhandenen +/// Kommandos nachstellen laesst, steht hier nicht. +fn draw_debug(frame: &mut Frame, area: Rect, app: &App, pick: usize) { + // ponytail: kein mitlaufender Ausschnitt wie im Grid — mehr Gegenstaende + // als Zeilen fallen stumm hinten raus. `core` hat sieben; der Ausschnitt + // kommt, wenn der erste Mod das sprengt. + let mut zeilen = Vec::new(); + for (i, def) in app.registries.items_ordered().iter().enumerate() { + let zeiger = if i == pick { "›" } else { " " }; + let menge = if def.stackable { "×10" } else { " " }; + zeilen.push( + Line::from(format!("{zeiger} {:<26} {menge} {}", def.name, def.id)) + .style(Style::new().fg(if i == pick { Color::Yellow } else { Color::Reset })), + ); + } + zeilen.push(Line::from("")); + zeilen.push( + Line::from("↑↓ wählen · Enter geben · h heilen · > abstiegen · x Raum leeren · Esc") + .style(Style::new().fg(Color::DarkGray)), + ); + + frame.render_widget( + Paragraph::new(zeilen) + .block(Block::bordered().title(" Werkbank ").title_bottom(" F12 ")), + area, + ); +} + /// Menue-Kampf (Charter: Dungeon Master / Etrian Odyssey). Links die Gegner mit /// Ziffern, rechts die Faehigkeiten mit Kosten und **Sperrgrund** — der kommt /// aus dem Spielkern, nicht aus der UI (Ticket 14). diff --git a/src/ui/screen.rs b/src/ui/screen.rs index 3662b3d..f3f80b5 100644 --- a/src/ui/screen.rs +++ b/src/ui/screen.rs @@ -3,7 +3,7 @@ use ratatui::crossterm::event::KeyCode; use crate::data::Registries; -use crate::game::{Command, CreatureRef, Direction, Game}; +use crate::game::{Cheat, Command, CreatureRef, Direction, Game}; /// Popups sind gewoehnliche Screens, die sich beim Rendern als Overlay /// ausweisen — ein gemeinsamer `Vec` statt `current` plus @@ -30,6 +30,12 @@ pub enum Screen { /// Meldungszeile im Hauptmenue: Ein Abstiegs-Roguelike lebt davon, dass der /// Lauf am Ende *erzaehlt* wird (Ticket 14). GameOver, + /// Werkbank. Immer mit gebaut statt hinter `cfg(debug_assertions)`: Das + /// waere ein `#[cfg]` an der Variante *und* an jedem `match` darueber, fuer + /// ein Spiel, das niemand ausliefert. Ein `#[cfg]` reicht spaeter nach. + Debug { + pick: usize, + }, /// **Flache Optionsliste**, kein Knotengraph (M4): Was erst nach einer /// Antwort erscheint, ist eine Option mit einer Bedingung. Dialog { @@ -96,6 +102,7 @@ impl Screen { KeyCode::Char('>') => Response::Command(Command::Descend), KeyCode::Char('g') => Response::Command(Command::PickUp), KeyCode::Char('t') => Response::Command(Command::Talk), + KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }), // Screen-Navigation ist reine UI und fasst `Game` nie an. KeyCode::Char('c') => Response::Push(Screen::CharacterSheet), KeyCode::Char('i') => Response::Push(Screen::Inventory { pick: 0 }), @@ -155,6 +162,7 @@ impl Screen { } KeyCode::Char('.') => Response::Command(Command::Wait), KeyCode::Char('c') => Response::Push(Screen::CharacterSheet), + KeyCode::F(12) => Response::Push(Screen::Debug { pick: 0 }), _ => Response::None, } } @@ -259,6 +267,32 @@ impl Screen { } } + Screen::Debug { pick } => { + let items = reg.items_ordered(); + if !items.is_empty() { + *pick = (*pick).min(items.len() - 1); + } + + match key { + KeyCode::Up | KeyCode::Char('k') => { + *pick = pick.saturating_sub(1); + Response::None + } + KeyCode::Down | KeyCode::Char('j') => { + *pick = (*pick + 1).min(items.len().saturating_sub(1)); + Response::None + } + KeyCode::Enter if !items.is_empty() => { + Response::Command(Command::Debug(Cheat::Give(items[*pick].id.clone()))) + } + KeyCode::Char('h') => Response::Command(Command::Debug(Cheat::Heal)), + KeyCode::Char('>') => Response::Command(Command::Debug(Cheat::Descend)), + KeyCode::Char('x') => Response::Command(Command::Debug(Cheat::ClearRoom)), + KeyCode::Esc | KeyCode::F(12) | KeyCode::Char('q') => Response::Pop, + _ => Response::None, + } + } + Screen::GameOver => Response::Quit, } }