Files
rustkeep/src/ui/app.rs
MDietermann b39491916f feat: m1 vertikaler durchstich — mod-lader, registries, attributberechnung, screen-stack
Ersetzt src/ vollstaendig: print_box, include_str! und enum ItemType fallen,
wie Ticket 01-03 es vorgezeichnet haben.

- data/: Mod-Lader mit zwei Suchpfaden, selbstheilender load_order.toml,
  drei Phasen und zweistufiger Deserialisierung (03/04)
- game/: Attributkatalog, Creature, Game::attribute ueber mehrere Quellen,
  Command mit Outcome (01/07/11/12)
- ui/: Screen-Stack mit Response, Grid-Widget, Press-Filter (06/11/12)
- mods/core/: echte TOML-Dateien auf der Platte statt include_str!

10 Tests, darunter der Borrow-Beweis fuer Registries-neben-Game, den
Ticket 12 nicht fuehren konnte.
2026-08-12 15:39:17 +02:00

206 lines
7.1 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;
use crate::game::{Attribute, CreatureRef, Game, Outcome};
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>,
}
pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option<String>) -> std::io::Result<()> {
let mut app = App {
game,
registries,
screens: vec![Screen::MainMenu],
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;
}
let Some(screen) = app.screens.last_mut() else { break Ok(()) };
match screen.handle_key(key.code) {
Response::None => {}
Response::Pop => {
app.screens.pop();
if app.screens.is_empty() {
break Ok(());
}
}
Response::Push(s) => app.screens.push(s),
Response::Command(cmd) => {
// 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();
}
}
Response::Quit => 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::CharacterSheet => draw_sheet(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 },
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 {} │ {} │ c Charakterbogen │ {}",
app.game.turn, app.game.player.name, note
))
.right_aligned()
.style(Style::new().fg(color)),
bar,
);
}
/// 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) {
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.
let rows: Vec<Row> = Attribute::ALL
.iter()
.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)")
};
Row::new(vec![a.name().to_string(), cell])
})
.collect();
frame.render_widget(
Table::new(rows, [Constraint::Length(20), Constraint::Min(0)])
.block(Block::bordered().title(" Attribute ")),
left,
);
let mut lines = Vec::new();
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)),
),
}
}
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,
);
}