//! Die Schleife. Blockierendes `event::read()`, kein Tick (Ticket 06). use ratatui::Frame; use ratatui::crossterm::event::{self, Event, KeyEventKind}; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Color, Style, Stylize}; use ratatui::text::Line; use ratatui::widgets::{Block, Paragraph, Row, Table}; use super::grid::GridWidget; use super::screen::{Response, Screen}; use super::theme; use crate::data::{Registries, save}; use crate::game::{Attribute, CreatureRef, DamageType, Game, Outcome, RunOutcome}; pub struct App { /// `game` und `registries` sind **getrennte Felder**, damit die disjunkten /// Borrows aus Ticket 07 funktionieren. pub game: Game, pub registries: Registries, screens: Vec, warnings: usize, log_name: Option, rejected: Option<&'static str>, } impl App { /// Der Spielzustand fuehrt, der Stack folgt: `Game.enemies` und `Game.over` /// sind die Wahrheit. Ohne das muesste jede Stelle, die einen Kampf startet /// oder beendet, daran denken, den Screen mitzuschieben. /// Beim Beenden und beim Betreten einer Etage. Ein gescheiterter Autosave /// bricht das Spiel nicht ab — er landet in der Statuszeile. fn autosave(&mut self) { if self.game.over.is_some() { return; } if let Err(e) = save::save(&self.game, &self.registries) { self.game.last = format!("Speichern fehlgeschlagen: {e}"); } } fn sync_screens(&mut self) { let in_combat = !self.game.enemies.is_empty(); let shows_combat = self.screens.iter().any(|s| matches!(s, Screen::Combat { .. })); if in_combat && !shows_combat { self.screens.push(Screen::Combat { pick: 0, target: 0, targeting: false }); } else if !in_combat { self.screens.retain(|s| !matches!(s, Screen::Combat { .. })); } // Ticket 11: `Game.talking_to` ist die Wahrheit, der Screen folgt — // dieselbe Regel wie beim Kampf. let talking = self.game.talking_to.is_some(); let shows_dialog = self.screens.iter().any(|s| matches!(s, Screen::Dialog { .. })); if talking && !shows_dialog { self.screens.push(Screen::Dialog { pick: 0 }); } else if !talking { self.screens .retain(|s| !matches!(s, Screen::Dialog { .. } | Screen::Shop { .. })); } // Die Aktion `open_shop` setzt nur eine Absicht; den Screen schiebt die // Schleife, weil `game/` keine Screens kennt. if std::mem::take(&mut self.game.open_shop) { self.screens.push(Screen::Shop { pick: 0, selling: false }); } if self.game.over.is_some() && !self.screens.iter().any(|s| matches!(s, Screen::GameOver)) { self.screens.push(Screen::GameOver); } } } pub fn run( game: Game, registries: Registries, warnings: usize, verworfen: usize, log_name: Option, ) -> std::io::Result<()> { let mut screens = vec![Screen::MainMenu]; // Die Rueckfrage steht **vor** dem Spiel, nicht als Zeile darin (Ticket // 09): Wer hier „nein" sagt, will den fehlenden Mod nachinstallieren — und // diese Wahl hat er nur jetzt, danach ist der verkrueppelte Held gespeichert. if verworfen > 0 { let wo = log_name.as_deref().unwrap_or("kein Log schreibbar"); screens.push(Screen::Confirm { text: format!( "{verworfen} Einträge aus dem Speicherstand fehlen (siehe logs/{wo}). Fortfahren?" ), on_yes: Box::new(Response::Pop), on_no: Box::new(Response::Quit), }); } let mut app = App { game, registries, screens, warnings, log_name, rejected: None, }; ratatui::run(|terminal| { loop { terminal.draw(|frame| draw(frame, &app))?; let Event::Key(key) = event::read()? else { continue }; // Pflichtfilter unter Windows: Ticket 12 hat gemessen, dass jeder // Tastendruck zwei Events erzeugt (Press und Release). if key.kind != KeyEventKind::Press { continue; } // Die Screens liegen in `app.screens`, gelesen wird aus `app.game` // und `app.registries` — drei getrennte Felder, disjunkte Borrows. let App { game, registries, screens, .. } = &mut app; let Some(screen) = screens.last_mut() else { break Ok(()) }; match screen.handle_key(key.code, game, registries) { Response::None => {} Response::Pop => { // Das Gespraech zu verlassen beendet es auch im Kern — // sonst schoebe `sync_screens` den Screen sofort zurueck. if matches!(app.screens.last(), Some(Screen::Dialog { .. })) { app.game.talking_to = None; } app.screens.pop(); if app.screens.is_empty() { app.autosave(); break Ok(()); } } Response::Push(s) => app.screens.push(s), Response::Command(cmd) => { let etage_vorher = app.game.floor.number; // Hier steht die Zusage aus Ticket 07 auf dem Pruefstand: // `&mut app.game` neben `&app.registries` in einem Aufruf. app.rejected = match app.game.execute(cmd, &app.registries) { Outcome::TurnPassed => None, Outcome::Rejected(reason) => Some(reason), }; 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. Dasselbe gilt fuer den // Kampf: `Game.enemies` ist die Wahrheit, der Screen folgt. app.sync_screens(); // Autosave beim **Betreten einer Etage** (Ticket 09). Bei // Tod stattdessen loeschen — Permadeath, und zwar erst hier // beim `GameOver`-Screen: Stuerzt das Spiel in der Sekunde // davor ab, ist der letzte Autosave noch da, und der stammt // vom Betreten der Etage, verschenkt also nichts. if app.game.over.is_some() { save::delete(); } else if app.game.floor.number != etage_vorher { app.autosave(); } } Response::Quit => { app.autosave(); break Ok(()); } } } }) } fn draw(frame: &mut Frame, app: &App) { let area = frame.area(); let first = app.screens.iter().rposition(|s| !s.is_overlay()).unwrap_or(0); for screen in &app.screens[first..] { match screen { Screen::MainMenu => draw_main_menu(frame, area, app), Screen::Explore => draw_explore(frame, area, app), Screen::Combat { pick, target, targeting } => { draw_combat(frame, area, app, *pick, *target, *targeting) } Screen::Inventory { pick } => draw_inventory(frame, area, app, *pick), Screen::CharacterSheet { pick, pending } => { draw_sheet(frame, area, app, *pick, pending) } Screen::Dialog { pick } => draw_dialog(frame, area, app, *pick), Screen::Shop { pick, selling } => draw_shop(frame, area, app, *pick, *selling), Screen::QuestLog => draw_quests(frame, area, app), Screen::Confirm { text, .. } => draw_confirm(frame, area, text), Screen::Debug { pick } => draw_debug(frame, area, app, *pick), Screen::GameOver => draw_game_over(frame, area, app), } } } fn draw_main_menu(frame: &mut Frame, area: Rect, app: &App) { let mut text = vec![ Line::from("rustkeep".bold()), Line::from(""), Line::from("Enter Dungeon betreten"), Line::from("q beenden"), ]; // Eine Zeile, kein Screen — der Inhalt gehoert in einen Texteditor // (Ticket 04). if app.warnings > 0 { let note = match &app.log_name { Some(file) => format!("{} Ladewarnungen — logs/{file}", app.warnings), None => format!("{} Ladewarnungen (Log nicht schreibbar)", app.warnings), }; text.push(Line::from("")); text.push(Line::from(note).style(Style::new().fg(Color::Yellow))); } frame.render_widget( Paragraph::new(text).block(Block::bordered().title(" Hauptmenü ")), area, ); } fn draw_explore(frame: &mut Frame, area: Rect, app: &App) { let block = Block::bordered().title(format!(" Etage {} ", app.game.floor.number)); let inner = block.inner(area); frame.render_widget(block, area); let [grid, bar] = Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).areas(inner); frame.render_widget( GridWidget { floor: &app.game.floor, player: app.game.player_pos, reg: &app.registries, }, grid, ); let (note, color) = match app.rejected { Some(reason) => (reason.to_string(), Color::Red), None => (app.game.last.clone(), Color::Cyan), }; frame.render_widget( Paragraph::new(format!( "Zug {} │ {} │ .warten g aufheben >absteigen i Inventar c Bogen │ {}", app.game.turn, app.game.player.name, note )) .right_aligned() .style(Style::new().fg(color)), bar, ); } /// Links das Inventar mit Cursor, rechts die Ausrüstung als **Schleife über die /// Slot-Registry** — Modder können Slots ergänzen, ohne dass hier etwas /// geändert wird (Ticket 02). fn draw_inventory(frame: &mut Frame, area: Rect, app: &App, pick: usize) { let block = Block::bordered().title(" Inventar "); let inner = block.inner(area); frame.render_widget(block, area); let [links, rechts] = Layout::horizontal([Constraint::Percentage(60), Constraint::Min(0)]).areas(inner); let mut zeilen = Vec::new(); for (i, inst) in app.game.player.inventory.iter().enumerate() { let def = app.registries.items.get(&inst.def); let name = app.game.item_label(inst, &app.registries); let preis = app.game.item_price(inst, &app.registries); let slot = def.and_then(|d| d.slot.as_deref()).unwrap_or("—"); let zeiger = if i == pick { "›" } else { " " }; let farbe = if i == pick { Color::Yellow } else { Color::Reset }; zeilen.push( Line::from(format!("{zeiger} {name:<30} {slot:<12} {preis:>5} G")) .style(Style::new().fg(farbe)), ); } if zeilen.is_empty() { zeilen.push(Line::from("leer").style(Style::new().fg(Color::DarkGray))); } zeilen.push(Line::from("")); zeilen.push( Line::from("↑↓ wählen · u benutzen · e anlegen · d wegwerfen · 1-9 ablegen · Esc") .style(Style::new().fg(Color::DarkGray)), ); frame.render_widget( Paragraph::new(zeilen).block(Block::bordered().title(" Getragen ")), links, ); let mut aus = Vec::new(); for (i, slot) in app.registries.slots_ordered().iter().enumerate() { let getragen = app.game.player.equipment.get(&slot.id); let text = match getragen { Some(inst) => app.game.item_label(inst, &app.registries), None => String::from("—"), }; let farbe = if getragen.is_some() { Color::Reset } else { Color::DarkGray }; aus.push( Line::from(format!("{} {:<10} {}", i + 1, slot.name, text)) .style(Style::new().fg(farbe)), ); } frame.render_widget( Paragraph::new(aus).block(Block::bordered().title(" Ausrüstung ")), rechts, ); } /// **Flache Optionsliste** — was erst nach einer Antwort erscheint, ist eine /// Option mit einer Bedingung (M4). fn draw_dialog(frame: &mut Frame, area: Rect, app: &App, pick: usize) { let Some(npc) = app.game.talking_npc(&app.registries) else { return }; let mut zeilen = vec![ Line::from(npc.greeting.clone()).style(Style::new().fg(Color::Gray)), Line::from(""), ]; for (i, option) in app.game.dialog_options(&app.registries).iter().enumerate() { let zeiger = if i == pick { "›" } else { " " }; let farbe = if i == pick { Color::Yellow } else { Color::Reset }; zeilen.push( Line::from(format!("{zeiger} {}. {}", i + 1, option.text)) .style(Style::new().fg(farbe)), ); } zeilen.push(Line::from("")); zeilen.push( Line::from("↑↓ wählen · Enter/1-9 antworten · Esc beenden") .style(Style::new().fg(Color::DarkGray)), ); frame.render_widget( Paragraph::new(zeilen).block(Block::bordered().title(format!(" {} ", npc.name))), area, ); } /// Zwei Spalten, damit die **Spanne** sichtbar ist: derselbe Gegenstand steht /// links teurer als er rechts einbringt. fn draw_shop(frame: &mut Frame, area: Rect, app: &App, pick: usize, selling: bool) { let block = Block::bordered().title(format!(" Laden · {} Gold ", app.game.gold())); let inner = block.inner(area); frame.render_widget(block, area); let [links, rechts] = Layout::horizontal([Constraint::Percentage(50), Constraint::Min(0)]).areas(inner); let zeile = |i: usize, text: String, aktiv: bool| { let zeiger = if aktiv && i == pick { "›" } else { " " }; let farbe = if aktiv && i == pick { Color::Yellow } else { Color::Reset }; Line::from(format!("{zeiger} {text}")).style(Style::new().fg(farbe)) }; let mut angebot: Vec = app .game .shop_stock() .unwrap_or(&[]) .iter() .enumerate() .map(|(i, inst)| { let name = app.game.item_label(inst, &app.registries); let preis = app.game.buy_price(inst, &app.registries); zeile(i, format!("{name:<28} {preis:>5} G"), !selling) }) .collect(); if angebot.is_empty() { angebot.push(Line::from("ausverkauft").style(Style::new().fg(Color::DarkGray))); } let mut eigenes: Vec = app .game .player .inventory .iter() .enumerate() .map(|(i, inst)| { let name = app.game.item_label(inst, &app.registries); let preis = app.game.sell_price(inst, &app.registries); zeile(i, format!("{name:<28} {preis:>5} G"), selling) }) .collect(); eigenes.push(Line::from("")); eigenes.push( Line::from("Tab wechseln · Enter handeln · Esc") .style(Style::new().fg(Color::DarkGray)), ); frame.render_widget( Paragraph::new(angebot).block(Block::bordered().title(" Angebot ")), links, ); frame.render_widget( Paragraph::new(eigenes).block(Block::bordered().title(" Verkaufen ")), rechts, ); } /// Laufende oben, erledigte darunter — erledigte bleiben stehen, weil sie es /// auch im Spielzustand tun (Ticket 08). fn draw_quests(frame: &mut Frame, area: Rect, app: &App) { let mut zeilen = Vec::new(); for q in &app.game.quests { let Some(def) = app.registries.quests.get(&q.quest) else { continue }; let (marke, farbe) = match q.done { true => ("✓", Color::DarkGray), false => ("•", Color::Reset), }; zeilen.push( Line::from(format!("{marke} {:<28} {}/{}", def.name, q.count.min(def.count), def.count)) .style(Style::new().fg(farbe)), ); if !def.description.is_empty() { zeilen.push( Line::from(format!(" {}", def.description)) .style(Style::new().fg(Color::DarkGray)), ); } } if zeilen.is_empty() { zeilen.push( Line::from("Noch nichts angenommen. Sprich mit jemandem.") .style(Style::new().fg(Color::DarkGray)), ); } zeilen.push(Line::from("")); zeilen.push(Line::from("Esc / q zurück").style(Style::new().fg(Color::DarkGray))); frame.render_widget( Paragraph::new(zeilen).block(Block::bordered().title(" Aufträge ")), area, ); } fn draw_confirm(frame: &mut Frame, area: Rect, text: &str) { let mut zeilen: Vec = text.lines().map(Line::from).collect(); zeilen.push(Line::from("")); zeilen.push(Line::from("j / Enter fortfahren").style(Style::new().fg(Color::Yellow))); zeilen.push(Line::from("n / Esc beenden").style(Style::new().fg(Color::DarkGray))); frame.render_widget( Paragraph::new(zeilen).block(Block::bordered().title(" Rückfrage ")), area, ); } /// 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). fn draw_combat( frame: &mut Frame, area: Rect, app: &App, pick: usize, target: usize, targeting: bool, ) { let block = Block::bordered().title(" Kampf "); let inner = block.inner(area); frame.render_widget(block, area); let [links, rechts] = Layout::horizontal([Constraint::Percentage(45), Constraint::Min(0)]) .areas(inner); let [gegner_area, log_area] = Layout::vertical([Constraint::Percentage(45), Constraint::Min(0)]).areas(links); // Gegner mit Zielziffer, Lebensbalken und **Ansage**. let mut zeilen = Vec::new(); for (i, e) in app.game.enemies.iter().enumerate() { let max = app .game .attribute(CreatureRef::Enemy(i), Attribute::MaxHealth, None, &app.registries); let anteil = if max > 0.0 { (e.health / max).clamp(0.0, 1.0) } else { 0.0 }; let balken = "█".repeat((anteil * 10.0).round() as usize); let gewaehlt = targeting && i == target; let marken: String = e.modifiers.iter().map(|m| m.symbol).collect(); zeilen.push( Line::from(format!( "{} {} {:<12} {:<10} {:>4.0}/{:<4.0} {marken}", if gewaehlt { "›" } else { " " }, i + 1, e.name, balken, e.health.max(0.0), max )) .style(Style::new().fg(if gewaehlt { Color::Yellow } else { Color::Reset })), ); // Die Ansage ist verbindlich — wer sie stoert, kostet dem Gegner den // Zug. Deshalb steht sie so gross wie die Lebensanzeige. let ansage = match app.game.intent_label(i, &app.registries) { Some(name) => format!(" ↳ {name} → dich"), None => String::from(" ↳ zögert"), }; zeilen.push(Line::from(ansage).style(Style::new().fg(Color::Magenta))); } if zeilen.is_empty() { zeilen.push(Line::from("—").style(Style::new().fg(Color::DarkGray))); } frame.render_widget( Paragraph::new(zeilen).block( Block::bordered().title(if targeting { " Gegner · aktiv " } else { " Gegner " }), ), gegner_area, ); // Kampflog, jüngste Zeile unten. let log: Vec = app .game .journal .iter() .rev() .take(log_area.height.saturating_sub(2) as usize) .rev() .map(|l| Line::from(l.as_str())) .collect(); frame.render_widget(Paragraph::new(log).block(Block::bordered().title(" Verlauf ")), log_area); // Faehigkeiten. let mut zeilen = Vec::new(); for (i, id) in app.game.abilities_of(CreatureRef::Player, &app.registries).iter().enumerate() { let Some(def) = app.registries.abilities.get(id) else { continue }; let block = app.game.ability_block(CreatureRef::Player, id, &app.registries); let zeiger = if !targeting && i == pick { "›" } else { " " }; let text = format!( "{zeiger} {:<16} {:>3} Ausd. {}", def.name, def.cost as u32, block.unwrap_or("") ); let farbe = match (block.is_some(), i == pick) { (true, _) => Color::DarkGray, (false, true) => Color::Yellow, (false, false) => Color::Reset, }; zeilen.push(Line::from(text).style(Style::new().fg(farbe))); } let p = &app.game.player; let max_health = app.game.attribute(CreatureRef::Player, Attribute::MaxHealth, None, &app.registries); let max_stamina = app.game.attribute(CreatureRef::Player, Attribute::MaxStamina, None, &app.registries); zeilen.push(Line::from("")); zeilen.push(Line::from(format!( "{} {:.0}/{:.0} Leben {:.0}/{:.0} Ausdauer", p.name, p.health.max(0.0), max_health, p.stamina, max_stamina ))); if !p.modifiers.is_empty() { let namen: Vec<&str> = p.modifiers.iter().map(|m| m.name.as_str()).collect(); zeilen.push(Line::from(namen.join(", ")).style(Style::new().fg(Color::Green))); } zeilen.push( Line::from("Tab Seite · ↑↓ wählen · Enter angreifen · i Inventar · . warten · c Bogen") .style(Style::new().fg(Color::DarkGray)), ); frame.render_widget( Paragraph::new(zeilen).block( Block::bordered() .title(if targeting { " Fähigkeiten " } else { " Fähigkeiten · aktiv " }), ), rechts, ); } /// 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, pick: usize, pending: &[u32; 5]) { let block = Block::bordered().title(" Charakterbogen "); let inner = block.inner(area); frame.render_widget(block, area); let [left, right] = Layout::horizontal([Constraint::Percentage(50); 2]).areas(inner); // Attribute: jeder Wert kommt aus Game::attribute, laeuft also durch alle // Quellen — das ist der Punkt des ganzen Durchstichs. // `resistance` faellt heraus: Ein einzelner Wert dafuer existiert nicht, es // sind sieben (Ticket 01). Sie stehen rechts als eigene Zeile. let rows: Vec = Attribute::ALL .iter() .filter(|a| **a != Attribute::Resistance) .map(|a| { let base = app.game.player.base.get(*a); let total = app.game .attribute(CreatureRef::Player, *a, None, &app.registries); let cell = if (total - base).abs() < f32::EPSILON { format!("{total:.0}") } else { format!("{total:.0} ({base:.0} Basis)") }; // Verteilbare Grundwerte tragen Cursor und Vormerkung — der Bogen // ist zugleich der Verteil-Screen (Ticket 15). let Some(i) = Attribute::ASSIGNABLE.iter().position(|x| x == a) else { return Row::new(vec![format!(" {}", a.name()), cell]); }; let zeiger = if i == pick { "›" } else { " " }; let vorgemerkt = if pending[i] > 0 { format!(" +{}", pending[i]) } else { String::new() }; Row::new(vec![format!("{zeiger} {} {vorgemerkt}", a.name()), cell]) .style(Style::new().fg(if i == pick { Color::Yellow } else { Color::Reset })) }) .collect(); frame.render_widget( Table::new(rows, [Constraint::Length(20), Constraint::Min(0)]) .block(Block::bordered().title(" Attribute ")), left, ); let mut lines = Vec::new(); // Erfahrung und Punkte stehen an `Game`, nicht am `Creature` (Ticket 15). let offen = app.game.unspent_points.saturating_sub(pending.iter().sum::()); lines.push(Line::from(format!( "Erfahrung {}/{}", app.game.current_exp, app.game.exp_needed(&app.registries) ))); if app.game.unspent_points > 0 { lines.push( Line::from(format!("{offen} Punkte offen · ←→ verteilen · Enter bestätigen")) .style(Style::new().fg(Color::Yellow)), ); } lines.push(Line::from("")); lines.push(Line::from("Herkunft".bold())); for id in &app.game.player.options { let name = app.registries.options.get(id).map(|o| o.name.as_str()).unwrap_or(id); lines.push(Line::from(format!(" {name}"))); } lines.push(Line::from("")); lines.push(Line::from("Ausrüstung".bold())); for slot in app.registries.slots_ordered() { let worn = app.game.player.equipment.get(&slot.id).and_then(|inst| { app.registries.items.get(&inst.def).map(|i| (i.name.as_str(), i.color.as_deref())) }); match worn { Some((name, color)) => lines.push(Line::from(vec![ format!(" {:<10} ", slot.name).into(), name.to_string().fg(theme::color(color)), ])), None => lines.push( Line::from(format!(" {:<10} —", slot.name)).style(Style::new().fg(Color::DarkGray)), ), } } // Resistenz ist typ-indiziert — nur was von null abweicht, ist erwaehnenswert. let widerstaende: Vec = DamageType::ALL .iter() .filter_map(|dt| { let r = app.game.resistance(CreatureRef::Player, *dt, &app.registries); (r.abs() > f32::EPSILON).then(|| format!("{} {:+.0}%", dt.name(), r * 100.0)) }) .collect(); if !widerstaende.is_empty() { lines.push(Line::from("")); lines.push(Line::from("Resistenz".bold())); lines.push(Line::from(format!(" {}", widerstaende.join(", ")))); } if !app.game.player.modifiers.is_empty() { lines.push(Line::from("")); lines.push(Line::from("Zustände".bold())); for m in &app.game.player.modifiers { lines.push(Line::from(format!(" {} ({} Züge)", m.name, m.turns_left))); } } lines.push(Line::from("")); lines.push(Line::from("Esc zurück").style(Style::new().fg(Color::DarkGray))); frame.render_widget( Paragraph::new(lines).block(Block::bordered().title(" Herkunft & Ausrüstung ")), right, ); }