Files
rustkeep/src/ui/app.rs
MDietermann 37b5820c5b feat(quests): Quests als Trigger mit Zaehler
Eine Quest IST ein Trigger mit count = N — kein zweiter Mechanismus.
Gezaehlt wird erst ab start_quest, erledigte bleiben mit done = true
stehen, und die Belohnung darf die naechste starten: das ist die Kette.

- fuenfzehnte Registry, Trigger per serde(flatten) eingebettet
- give_item als Aktion, damit eine Belohnung mehr als Erfahrung zahlt
- QuestLog auf q, Fortschritt im Speicherstand, unbekannte IDs verworfen
- eine Funktion entscheidet fuer alle Traeger, wann ein Trigger feuert

Befund: Die Ordnungszahlen im Aktionskatalog widersprechen sich — vier
Tickets zaehlten je vom eigenen Stand. Sie sind raus, CONTEXT.md fuehrt
die Liste.
2026-08-13 07:43:46 +02:00

747 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<Screen>,
warnings: usize,
log_name: Option<String>,
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<String>,
) -> 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<Line> = 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<Line> = 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<Line> = 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<Line> = 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<Row> = 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::<u32>());
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<String> = 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,
);
}