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.
747 lines
28 KiB
Rust
747 lines
28 KiB
Rust
//! 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,
|
||
);
|
||
}
|