//! 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, warnings: usize, log_name: Option, rejected: Option<&'static str>, } pub fn run(game: Game, registries: Registries, warnings: usize, log_name: Option) -> 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 = 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, ); }